fixing issues of integration with wordpress plugin
This commit is contained in:
@@ -78,6 +78,7 @@ class SiteSerializer(serializers.ModelSerializer):
|
||||
'industry', 'industry_name', 'industry_slug',
|
||||
'is_active', 'status',
|
||||
'site_type', 'hosting_type', 'seo_metadata',
|
||||
'wp_api_key', # WordPress API key (single source of truth for integration)
|
||||
'sectors_count', 'active_sectors_count', 'selected_sectors',
|
||||
'can_add_sectors', 'keywords_count', 'has_integration',
|
||||
'created_at', 'updated_at'
|
||||
@@ -86,6 +87,7 @@ class SiteSerializer(serializers.ModelSerializer):
|
||||
# Explicitly specify required fields for clarity
|
||||
extra_kwargs = {
|
||||
'industry': {'required': True, 'error_messages': {'required': 'Industry is required when creating a site.'}},
|
||||
'wp_api_key': {'read_only': True}, # Only set via generate-api-key endpoint
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@@ -217,6 +217,7 @@ class IntegrationService:
|
||||
dict: Connection test result with detailed health status
|
||||
"""
|
||||
import requests
|
||||
from django.utils import timezone
|
||||
|
||||
config = integration.config_json
|
||||
|
||||
@@ -324,13 +325,6 @@ class IntegrationService:
|
||||
health_checks['plugin_has_api_key']
|
||||
)
|
||||
|
||||
# Save site_url to config if successful and not already set
|
||||
if is_healthy and not config.get('site_url'):
|
||||
config['site_url'] = site_url
|
||||
integration.config_json = config
|
||||
integration.save(update_fields=['config_json'])
|
||||
logger.info(f"[IntegrationService] Saved site_url to integration {integration.id} config: {site_url}")
|
||||
|
||||
# Build response message
|
||||
if is_healthy:
|
||||
message = "✅ WordPress integration is connected and authenticated via API key"
|
||||
@@ -347,6 +341,28 @@ class IntegrationService:
|
||||
else:
|
||||
message = "❌ WordPress connection failed"
|
||||
|
||||
# Update integration status based on connection test result
|
||||
if is_healthy:
|
||||
integration.sync_status = 'success'
|
||||
integration.sync_error = None
|
||||
integration.last_sync_at = timezone.now()
|
||||
logger.info(f"[IntegrationService] Connection test passed, set sync_status to 'success' for integration {integration.id}")
|
||||
else:
|
||||
integration.sync_status = 'failed'
|
||||
integration.sync_error = message
|
||||
logger.warning(f"[IntegrationService] Connection test failed, set sync_status to 'failed' for integration {integration.id}")
|
||||
|
||||
# Save site_url to config if successful and not already set
|
||||
if is_healthy and not config.get('site_url'):
|
||||
config['site_url'] = site_url
|
||||
integration.config_json = config
|
||||
|
||||
# Save all changes to integration
|
||||
integration.save()
|
||||
|
||||
if is_healthy and not config.get('site_url'):
|
||||
logger.info(f"[IntegrationService] Saved site_url to integration {integration.id} config: {site_url}")
|
||||
|
||||
return {
|
||||
'success': is_healthy,
|
||||
'fully_functional': is_healthy,
|
||||
|
||||
@@ -127,33 +127,22 @@ class PublisherService:
|
||||
# Get destination config
|
||||
destination_config = {}
|
||||
|
||||
# If content has site, try to get integration config
|
||||
# Get WordPress config directly from Site model (no SiteIntegration needed)
|
||||
if hasattr(content, 'site') and content.site:
|
||||
from igny8_core.business.integration.models import SiteIntegration
|
||||
logger.info(f"[PublisherService._publish_to_destination] 🔍 Looking for integration: site={content.site.name}, platform={destination}")
|
||||
integration = SiteIntegration.objects.filter(
|
||||
site=content.site,
|
||||
platform=destination,
|
||||
is_active=True
|
||||
).first()
|
||||
site = content.site
|
||||
logger.info(f"[PublisherService._publish_to_destination] 🔍 Getting config from site: {site.name}")
|
||||
|
||||
if integration:
|
||||
logger.info(f"[PublisherService._publish_to_destination] ✅ Integration found: id={integration.id}")
|
||||
# Merge config_json (site_url, etc.)
|
||||
destination_config.update(integration.config_json or {})
|
||||
|
||||
# API key is stored in Site.wp_api_key (SINGLE source of truth)
|
||||
if integration.site.wp_api_key:
|
||||
destination_config['api_key'] = integration.site.wp_api_key
|
||||
|
||||
logger.info(f"[PublisherService._publish_to_destination] 🔑 Config merged: has_api_key={bool(destination_config.get('api_key'))}, has_site_url={bool(destination_config.get('site_url'))}")
|
||||
|
||||
# Ensure site_url is set (from config or from site model)
|
||||
if not destination_config.get('site_url'):
|
||||
destination_config['site_url'] = content.site.url
|
||||
logger.info(f"[PublisherService._publish_to_destination] 🌐 Using site.url: {content.site.url}")
|
||||
# API key is stored in Site.wp_api_key (SINGLE source of truth)
|
||||
if site.wp_api_key:
|
||||
destination_config['api_key'] = site.wp_api_key
|
||||
logger.info(f"[PublisherService._publish_to_destination] 🔑 API key found on site")
|
||||
else:
|
||||
logger.warning(f"[PublisherService._publish_to_destination] ⚠️ No integration found for site={content.site.name}, platform={destination}")
|
||||
logger.error(f"[PublisherService._publish_to_destination] ❌ No API key found on site {site.name}")
|
||||
raise ValueError(f"WordPress API key not configured for site {site.name}. Please generate an API key in Site Settings.")
|
||||
|
||||
# Use Site.domain as site_url
|
||||
destination_config['site_url'] = site.domain or site.url
|
||||
logger.info(f"[PublisherService._publish_to_destination] 🌐 Using site URL: {destination_config['site_url']}")
|
||||
|
||||
# Publish via adapter
|
||||
logger.info(f"[PublisherService._publish_to_destination] 🚀 Calling adapter.publish() with config keys: {list(destination_config.keys())}")
|
||||
|
||||
@@ -12,6 +12,7 @@ from igny8_core.api.base import SiteSectorModelViewSet
|
||||
from igny8_core.api.permissions import IsAuthenticatedAndActive, IsEditorOrAbove
|
||||
from igny8_core.api.response import success_response, error_response
|
||||
from igny8_core.api.throttles import DebugScopedRateThrottle
|
||||
from igny8_core.auth.models import Site
|
||||
from igny8_core.business.integration.models import SiteIntegration
|
||||
from igny8_core.business.integration.services.integration_service import IntegrationService
|
||||
from igny8_core.business.integration.services.sync_service import SyncService
|
||||
@@ -131,19 +132,23 @@ class IntegrationViewSet(SiteSectorModelViewSet):
|
||||
permission_classes=[AllowAny], throttle_classes=[NoThrottle])
|
||||
def test_connection_collection(self, request):
|
||||
"""
|
||||
Collection-level test connection endpoint for frontend convenience.
|
||||
Test WordPress connection using Site.wp_api_key (single source of truth).
|
||||
|
||||
POST /api/v1/integration/integrations/test-connection/
|
||||
|
||||
Body:
|
||||
{
|
||||
"site_id": 123,
|
||||
"api_key": "...",
|
||||
"site_url": "https://example.com"
|
||||
"site_id": 123
|
||||
}
|
||||
|
||||
Tests:
|
||||
1. WordPress site is reachable
|
||||
2. IGNY8 plugin is installed
|
||||
3. Plugin has API key configured (matching Site.wp_api_key)
|
||||
"""
|
||||
import requests as http_requests
|
||||
|
||||
site_id = request.data.get('site_id')
|
||||
api_key = request.data.get('api_key')
|
||||
site_url = request.data.get('site_url')
|
||||
|
||||
if not site_id:
|
||||
return error_response('site_id is required', None, status.HTTP_400_BAD_REQUEST, request)
|
||||
@@ -155,80 +160,146 @@ class IntegrationViewSet(SiteSectorModelViewSet):
|
||||
except (Site.DoesNotExist, ValueError, TypeError):
|
||||
return error_response('Site not found or invalid', None, status.HTTP_404_NOT_FOUND, request)
|
||||
|
||||
# Authentication: accept either authenticated user OR matching API key in body
|
||||
api_key = request.data.get('api_key') or api_key
|
||||
authenticated = False
|
||||
# If request has a valid user and belongs to same account, allow
|
||||
if hasattr(request, 'user') and getattr(request.user, 'is_authenticated', False):
|
||||
try:
|
||||
# If user has account, ensure site belongs to user's account
|
||||
if site.account == request.user.account:
|
||||
authenticated = True
|
||||
except Exception:
|
||||
# Ignore and fallback to api_key check
|
||||
pass
|
||||
# Authentication: user must be authenticated and belong to same account
|
||||
if not hasattr(request, 'user') or not getattr(request.user, 'is_authenticated', False):
|
||||
return error_response('Authentication required', None, status.HTTP_403_FORBIDDEN, request)
|
||||
|
||||
try:
|
||||
if site.account != request.user.account:
|
||||
return error_response('Site does not belong to your account', None, status.HTTP_403_FORBIDDEN, request)
|
||||
except Exception:
|
||||
return error_response('Authentication failed', None, status.HTTP_403_FORBIDDEN, request)
|
||||
|
||||
# If not authenticated via session, allow if provided api_key matches site's stored wp_api_key
|
||||
if not authenticated:
|
||||
stored_key = getattr(site, 'wp_api_key', None)
|
||||
if stored_key and api_key and str(api_key) == str(stored_key):
|
||||
authenticated = True
|
||||
elif not stored_key:
|
||||
# API key not set on site - provide helpful error message
|
||||
return error_response(
|
||||
'API key not configured for this site. Please generate an API key in the IGNY8 app and ensure it is saved to the site.',
|
||||
None,
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
request
|
||||
)
|
||||
elif api_key and stored_key and str(api_key) != str(stored_key):
|
||||
# API key provided but doesn't match
|
||||
return error_response(
|
||||
'Invalid API key. The provided API key does not match the one stored for this site.',
|
||||
None,
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
request
|
||||
)
|
||||
|
||||
if not authenticated:
|
||||
return error_response('Authentication credentials were not provided.', None, status.HTTP_403_FORBIDDEN, request)
|
||||
|
||||
# Try to find an existing integration for this site+platform
|
||||
integration = SiteIntegration.objects.filter(site=site, platform='wordpress').first()
|
||||
|
||||
# If not found, create and save the integration to database (for status tracking, not credentials)
|
||||
integration_created = False
|
||||
if not integration:
|
||||
integration = SiteIntegration.objects.create(
|
||||
account=site.account,
|
||||
site=site,
|
||||
platform='wordpress',
|
||||
platform_type='cms',
|
||||
config_json={'site_url': site_url} if site_url else {},
|
||||
credentials_json={}, # API key is stored in Site.wp_api_key, not here
|
||||
is_active=True,
|
||||
sync_enabled=True
|
||||
# Get stored API key from Site model (single source of truth)
|
||||
stored_api_key = site.wp_api_key
|
||||
if not stored_api_key:
|
||||
return error_response(
|
||||
'API key not configured. Please generate an API key first.',
|
||||
None,
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
request
|
||||
)
|
||||
integration_created = True
|
||||
logger.info(f"[IntegrationViewSet] Created WordPress integration {integration.id} for site {site.id}")
|
||||
|
||||
service = IntegrationService()
|
||||
# Mark this as initial connection test since API key was provided in request body
|
||||
# This allows the test to pass even if WordPress plugin hasn't stored the key yet
|
||||
is_initial_connection = bool(api_key and request.data.get('api_key'))
|
||||
result = service._test_wordpress_connection(integration, is_initial_connection=is_initial_connection)
|
||||
# Get site URL
|
||||
site_url = site.domain or site.url
|
||||
if not site_url:
|
||||
return error_response(
|
||||
'Site URL not configured',
|
||||
None,
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
request
|
||||
)
|
||||
|
||||
if result.get('success'):
|
||||
# Include integration_id in response so plugin can store it
|
||||
result['integration_id'] = integration.id
|
||||
result['integration_created'] = integration_created
|
||||
return success_response(result, request=request)
|
||||
else:
|
||||
# If test failed and we just created integration, delete it
|
||||
if integration_created:
|
||||
integration.delete()
|
||||
logger.info(f"[IntegrationViewSet] Deleted integration {integration.id} due to failed connection test")
|
||||
return error_response(result.get('message', 'Connection test failed'), None, status.HTTP_400_BAD_REQUEST, request)
|
||||
# Health check results
|
||||
health_checks = {
|
||||
'site_url_configured': True,
|
||||
'api_key_configured': True,
|
||||
'wp_rest_api_reachable': False,
|
||||
'plugin_installed': False,
|
||||
'plugin_has_api_key': False,
|
||||
'api_key_verified': False, # NEW: Verifies IGNY8 and WordPress have SAME key
|
||||
}
|
||||
issues = []
|
||||
|
||||
try:
|
||||
# Check 1: WordPress REST API reachable
|
||||
try:
|
||||
rest_response = http_requests.get(
|
||||
f"{site_url.rstrip('/')}/wp-json/",
|
||||
timeout=10
|
||||
)
|
||||
if rest_response.status_code == 200:
|
||||
health_checks['wp_rest_api_reachable'] = True
|
||||
else:
|
||||
issues.append(f"WordPress REST API not reachable: HTTP {rest_response.status_code}")
|
||||
except Exception as e:
|
||||
issues.append(f"WordPress REST API unreachable: {str(e)}")
|
||||
|
||||
# Check 2: IGNY8 Plugin installed (public status endpoint)
|
||||
try:
|
||||
status_response = http_requests.get(
|
||||
f"{site_url.rstrip('/')}/wp-json/igny8/v1/status",
|
||||
timeout=10
|
||||
)
|
||||
if status_response.status_code == 200:
|
||||
health_checks['plugin_installed'] = True
|
||||
|
||||
status_data = status_response.json()
|
||||
plugin_data = status_data.get('data', status_data)
|
||||
|
||||
if plugin_data.get('connected') or plugin_data.get('has_api_key'):
|
||||
health_checks['plugin_has_api_key'] = True
|
||||
else:
|
||||
issues.append("Plugin installed but no API key configured in WordPress")
|
||||
else:
|
||||
issues.append(f"IGNY8 plugin not found: HTTP {status_response.status_code}")
|
||||
except Exception as e:
|
||||
issues.append(f"Cannot detect IGNY8 plugin: {str(e)}")
|
||||
|
||||
# Check 3: Verify API keys MATCH by making authenticated request
|
||||
# This is the CRITICAL check - WordPress must accept our API key
|
||||
if health_checks['plugin_installed'] and health_checks['plugin_has_api_key']:
|
||||
try:
|
||||
# Make authenticated request using Site.wp_api_key to dedicated verify endpoint
|
||||
verify_response = http_requests.get(
|
||||
f"{site_url.rstrip('/')}/wp-json/igny8/v1/verify-key",
|
||||
headers={
|
||||
'X-IGNY8-API-KEY': stored_api_key,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
timeout=10
|
||||
)
|
||||
if verify_response.status_code == 200:
|
||||
health_checks['api_key_verified'] = True
|
||||
elif verify_response.status_code in [401, 403]:
|
||||
issues.append("API key mismatch - WordPress has different key than IGNY8. Please copy the API key from IGNY8 to WordPress plugin settings.")
|
||||
else:
|
||||
issues.append(f"API key verification failed: HTTP {verify_response.status_code}")
|
||||
except Exception as e:
|
||||
issues.append(f"API key verification request failed: {str(e)}")
|
||||
|
||||
# Determine overall status - MUST include api_key_verified for true connection
|
||||
is_healthy = (
|
||||
health_checks['api_key_configured'] and
|
||||
health_checks['wp_rest_api_reachable'] and
|
||||
health_checks['plugin_installed'] and
|
||||
health_checks['plugin_has_api_key'] and
|
||||
health_checks['api_key_verified'] # CRITICAL: keys must match
|
||||
)
|
||||
|
||||
# Build message with clear guidance
|
||||
if is_healthy:
|
||||
message = "✅ WordPress integration is fully connected and verified"
|
||||
elif not health_checks['wp_rest_api_reachable']:
|
||||
message = "❌ Cannot reach WordPress site"
|
||||
elif not health_checks['plugin_installed']:
|
||||
message = "⚠️ WordPress is reachable but IGNY8 plugin not installed"
|
||||
elif not health_checks['plugin_has_api_key']:
|
||||
message = "⚠️ Plugin installed but no API key configured in WordPress"
|
||||
elif not health_checks['api_key_verified']:
|
||||
message = "⚠️ API key mismatch - copy the API key from IGNY8 to WordPress plugin"
|
||||
else:
|
||||
message = "❌ WordPress connection failed"
|
||||
|
||||
return success_response({
|
||||
'success': is_healthy,
|
||||
'message': message,
|
||||
'site_id': site.id,
|
||||
'site_name': site.name,
|
||||
'site_url': site_url,
|
||||
'api_key_configured': bool(stored_api_key),
|
||||
'health_checks': health_checks,
|
||||
'issues': issues if issues else None,
|
||||
}, request=request)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"WordPress connection test failed: {e}")
|
||||
return error_response(
|
||||
f'Connection test failed: {str(e)}',
|
||||
None,
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
request
|
||||
)
|
||||
|
||||
@extend_schema(tags=['Integration'])
|
||||
@action(detail=True, methods=['post'])
|
||||
@@ -808,42 +879,71 @@ class IntegrationViewSet(SiteSectorModelViewSet):
|
||||
site.wp_api_key = api_key
|
||||
site.save(update_fields=['wp_api_key'])
|
||||
|
||||
# Get or create SiteIntegration (for integration status/config, NOT credentials)
|
||||
integration, created = SiteIntegration.objects.get_or_create(
|
||||
site=site,
|
||||
platform='wordpress',
|
||||
defaults={
|
||||
'account': site.account,
|
||||
'platform': 'wordpress',
|
||||
'platform_type': 'cms',
|
||||
'is_active': True,
|
||||
'sync_enabled': True,
|
||||
'credentials_json': {}, # Empty - API key is on Site model
|
||||
'config_json': {}
|
||||
}
|
||||
)
|
||||
|
||||
# If integration already exists, just ensure it's active
|
||||
if not created:
|
||||
integration.is_active = True
|
||||
integration.sync_enabled = True
|
||||
# Clear any old credentials_json API key (migrate to Site.wp_api_key)
|
||||
if integration.credentials_json.get('api_key'):
|
||||
integration.credentials_json = {}
|
||||
integration.save()
|
||||
|
||||
logger.info(
|
||||
f"Generated new API key for site {site.name} (ID: {site_id}), "
|
||||
f"stored in Site.wp_api_key (single source of truth)"
|
||||
)
|
||||
|
||||
# Serialize the integration with the new key
|
||||
serializer = self.get_serializer(integration)
|
||||
return success_response({
|
||||
'api_key': api_key,
|
||||
'site_id': site.id,
|
||||
'site_name': site.name,
|
||||
'site_url': site.domain or site.url,
|
||||
'message': 'API key generated successfully. WordPress integration is ready.',
|
||||
}, request=request)
|
||||
|
||||
@action(detail=False, methods=['post'], url_path='revoke-api-key')
|
||||
def revoke_api_key(self, request):
|
||||
"""
|
||||
Revoke (delete) the API key for a site's WordPress integration.
|
||||
|
||||
POST /api/v1/integration/integrations/revoke-api-key/
|
||||
|
||||
Body:
|
||||
{
|
||||
"site_id": 5
|
||||
}
|
||||
"""
|
||||
site_id = request.data.get('site_id')
|
||||
if not site_id:
|
||||
return error_response(
|
||||
'Site ID is required',
|
||||
None,
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
request
|
||||
)
|
||||
|
||||
try:
|
||||
site = Site.objects.get(id=site_id)
|
||||
except Site.DoesNotExist:
|
||||
return error_response(
|
||||
f'Site with ID {site_id} not found',
|
||||
None,
|
||||
status.HTTP_404_NOT_FOUND,
|
||||
request
|
||||
)
|
||||
|
||||
# Verify user has access to this site
|
||||
if site.account != request.user.account:
|
||||
return error_response(
|
||||
'You do not have permission to modify this site',
|
||||
None,
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
request
|
||||
)
|
||||
|
||||
# SINGLE SOURCE OF TRUTH: Remove API key from Site.wp_api_key
|
||||
site.wp_api_key = None
|
||||
site.save(update_fields=['wp_api_key'])
|
||||
|
||||
logger.info(
|
||||
f"Revoked API key for site {site.name} (ID: {site_id})"
|
||||
)
|
||||
|
||||
return success_response({
|
||||
'integration': serializer.data,
|
||||
'api_key': api_key,
|
||||
'message': f"API key {'generated' if created else 'regenerated'} successfully",
|
||||
'site_id': site.id,
|
||||
'site_name': site.name,
|
||||
'message': 'API key revoked successfully. WordPress integration is now disconnected.',
|
||||
}, request=request)
|
||||
|
||||
|
||||
|
||||
@@ -429,11 +429,11 @@ class ContentResource(resources.ModelResource):
|
||||
@admin.register(Content)
|
||||
class ContentAdmin(ImportExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
|
||||
resource_class = ContentResource
|
||||
list_display = ['title', 'content_type', 'content_structure', 'site', 'sector', 'source', 'status', 'word_count', 'get_taxonomy_count', 'created_at']
|
||||
list_filter = ['status', 'content_type', 'content_structure', 'source', 'site', 'sector', 'cluster', 'word_count', 'created_at']
|
||||
list_display = ['title', 'content_type', 'content_structure', 'site', 'sector', 'source', 'status', 'site_status', 'scheduled_publish_at', 'word_count', 'get_taxonomy_count', 'created_at']
|
||||
list_filter = ['status', 'site_status', 'content_type', 'content_structure', 'source', 'site', 'sector', 'cluster', 'word_count', 'created_at']
|
||||
search_fields = ['title', 'content_html', 'external_url', 'meta_title', 'primary_keyword']
|
||||
ordering = ['-created_at']
|
||||
readonly_fields = ['created_at', 'updated_at', 'word_count', 'get_tags_display', 'get_categories_display']
|
||||
readonly_fields = ['created_at', 'updated_at', 'word_count', 'site_status_updated_at', 'get_tags_display', 'get_categories_display']
|
||||
autocomplete_fields = ['cluster', 'site', 'sector']
|
||||
inlines = [ContentTaxonomyInline]
|
||||
actions = [
|
||||
@@ -449,6 +449,10 @@ class ContentAdmin(ImportExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
|
||||
('Basic Info', {
|
||||
'fields': ('title', 'site', 'sector', 'cluster', 'status')
|
||||
}),
|
||||
('Publishing Status', {
|
||||
'fields': ('site_status', 'scheduled_publish_at', 'site_status_updated_at'),
|
||||
'description': 'WordPress/external site publishing status. Managed by automated publishing scheduler.'
|
||||
}),
|
||||
('Content Classification', {
|
||||
'fields': ('content_type', 'content_structure', 'source')
|
||||
}),
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
@@ -255,7 +255,7 @@ def process_scheduled_publications() -> Dict[str, Any]:
|
||||
due_content = Content.objects.filter(
|
||||
site_status='scheduled',
|
||||
scheduled_publish_at__lte=now
|
||||
).select_related('site', 'task')
|
||||
).select_related('site', 'sector', 'cluster')
|
||||
|
||||
for content in due_content:
|
||||
results['processed'] += 1
|
||||
@@ -284,11 +284,9 @@ def process_scheduled_publications() -> Dict[str, Any]:
|
||||
continue
|
||||
|
||||
# Queue the WordPress publishing task
|
||||
task_id = content.task_id if hasattr(content, 'task') and content.task else None
|
||||
publish_content_to_wordpress.delay(
|
||||
content_id=content.id,
|
||||
site_integration_id=site_integration.id,
|
||||
task_id=task_id
|
||||
site_integration_id=site_integration.id
|
||||
)
|
||||
|
||||
logger.info(f"Queued content {content.id} for WordPress publishing")
|
||||
|
||||
371
docs/40-WORKFLOWS/SCHEDULED-CONTENT-PUBLISHING.md
Normal file
371
docs/40-WORKFLOWS/SCHEDULED-CONTENT-PUBLISHING.md
Normal 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)
|
||||
@@ -28,9 +28,10 @@
|
||||
IGNY8 integrates with WordPress sites through a **custom WordPress plugin** (`igny8-wp-bridge`) that:
|
||||
- Receives content from IGNY8 via a custom REST endpoint (`/wp-json/igny8/v1/publish`)
|
||||
- Sends status updates back to IGNY8 via webhooks
|
||||
- Authenticates using API keys stored in both systems
|
||||
- **Authenticates using API key ONLY** (stored in Site.wp_api_key - single source of truth)
|
||||
- Auto-updates via IGNY8 plugin distribution system (v1.7.0+)
|
||||
- Supports advanced template rendering with image layouts
|
||||
- **No WordPress admin credentials required** (username/password authentication deprecated)
|
||||
|
||||
### Communication Pattern
|
||||
|
||||
@@ -108,19 +109,22 @@ IGNY8 App ←→ WordPress Site
|
||||
**Step 2: User clicks "Generate API Key"**
|
||||
- Frontend calls: `POST /v1/integration/integrations/generate-api-key/`
|
||||
- Body: `{ "site_id": 123 }`
|
||||
- Backend creates/updates `SiteIntegration` record with new API key
|
||||
- Backend stores API key in `Site.wp_api_key` field (SINGLE source of truth)
|
||||
- Creates/updates `SiteIntegration` record with empty credentials_json
|
||||
|
||||
**Step 3: User configures WordPress plugin**
|
||||
- Configures plugin with:
|
||||
- IGNY8 API URL: `https://api.igny8.com`
|
||||
- Site API Key: (copied from IGNY8)
|
||||
- Site ID: (shown in IGNY8)
|
||||
- **Note:** No WordPress admin credentials needed
|
||||
|
||||
**Step 4: Test Connection**
|
||||
- User clicks "Test Connection" in either app
|
||||
- IGNY8 calls: `GET {wordpress_url}/wp-json/wp/v2/users/me`
|
||||
- Uses API key in `X-IGNY8-API-KEY` header
|
||||
- Success: Connection verified, `is_active` set to true, plugin registers installation
|
||||
- Plugin calls: `POST https://api.igny8.com/api/v1/integration/integrations/test-connection/`
|
||||
- Headers: `Authorization: Bearer {api_key}`
|
||||
- Body: `{ "site_id": 123, "api_key": "...", "site_url": "https://..." }`
|
||||
- Backend validates API key against `Site.wp_api_key`
|
||||
- Success: SiteIntegration created with empty credentials_json, plugin registers installation
|
||||
- Failure: Error message displayed
|
||||
|
||||
### 2.4 Data Created During Setup
|
||||
@@ -137,14 +141,100 @@ IGNY8 App ←→ WordPress Site
|
||||
"site_url": "https://example.com"
|
||||
},
|
||||
"credentials_json": {
|
||||
"api_key": "igny8_xxxxxxxxxxxxxxxxxxxx"
|
||||
"plugin_version": "1.3.4",
|
||||
"debug_enabled": false
|
||||
},
|
||||
"is_active": true,
|
||||
"sync_enabled": true,
|
||||
"sync_status": "pending"
|
||||
"sync_status": "pending",
|
||||
"_note": "API key stored in Site.wp_api_key, not in credentials_json"
|
||||
}
|
||||
```
|
||||
|
||||
**Site Model (API Key Storage):**
|
||||
```json
|
||||
{
|
||||
"id": 123,
|
||||
"name": "Example Site",
|
||||
"url": "https://example.com",
|
||||
"wp_api_key": "igny8_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
|
||||
"hosting_type": "wordpress"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2.5 Authentication Architecture (v1.3.4+)
|
||||
|
||||
### API Key as Single Source of Truth
|
||||
|
||||
**Storage:**
|
||||
- API key stored in `Site.wp_api_key` field (Django backend)
|
||||
- Plugin stores same API key in WordPress options table: `igny8_api_key`
|
||||
- SiteIntegration.credentials_json does NOT contain API key
|
||||
|
||||
**Authentication Flow (IGNY8 → WordPress):**
|
||||
```python
|
||||
# Backend: publisher_service.py
|
||||
destination_config = {
|
||||
'site_url': integration.config_json.get('site_url'),
|
||||
'api_key': integration.site.wp_api_key # From Site model
|
||||
}
|
||||
|
||||
# WordPress Adapter: wordpress_adapter.py
|
||||
headers = {
|
||||
'Authorization': f'Bearer {api_key}',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
requests.post(f"{site_url}/wp-json/igny8/v1/publish", headers=headers, json=payload)
|
||||
```
|
||||
|
||||
**Authentication Flow (WordPress → IGNY8):**
|
||||
```php
|
||||
// Plugin: class-igny8-api.php
|
||||
$api_key = get_option('igny8_api_key');
|
||||
$headers = array(
|
||||
'Authorization' => 'Bearer ' . $api_key,
|
||||
'Content-Type' => 'application/json'
|
||||
);
|
||||
wp_remote_post('https://api.igny8.com/api/v1/...', array('headers' => $headers));
|
||||
```
|
||||
|
||||
**Validation (WordPress Side):**
|
||||
```php
|
||||
// Plugin: class-igny8-rest-api.php
|
||||
public function check_permission($request) {
|
||||
// Check X-IGNY8-API-KEY header
|
||||
$header_api_key = $request->get_header('x-igny8-api-key');
|
||||
|
||||
// Check Authorization Bearer header
|
||||
$auth_header = $request->get_header('Authorization');
|
||||
|
||||
$stored_api_key = get_option('igny8_api_key');
|
||||
|
||||
if (hash_equals($stored_api_key, $header_api_key) ||
|
||||
strpos($auth_header, 'Bearer ' . $stored_api_key) !== false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return new WP_Error('rest_forbidden', 'Invalid API key', array('status' => 401));
|
||||
}
|
||||
```
|
||||
|
||||
### Deprecated Authentication Methods
|
||||
|
||||
**No Longer Supported (removed in v1.3.4):**
|
||||
- ❌ Username/password authentication
|
||||
- ❌ App passwords via WordPress REST API
|
||||
- ❌ OAuth/token exchange
|
||||
- ❌ Webhook signature validation (webhooks deprecated)
|
||||
- ❌ Storing API key in SiteIntegration.credentials_json
|
||||
|
||||
**Legacy Fields (do not use):**
|
||||
- `Site.wp_username` - deprecated
|
||||
- `Site.wp_app_password` - deprecated
|
||||
- `Site.wp_url` - deprecated (use SiteIntegration.config_json.site_url)
|
||||
|
||||
---
|
||||
|
||||
## 3. Manual Publishing Flow
|
||||
@@ -415,18 +505,119 @@ Refreshes understanding of WordPress site:
|
||||
|-------|------|---------|
|
||||
| id | AutoField | Primary key |
|
||||
| account | FK(Account) | Owner account |
|
||||
| site | FK(Site) | IGNY8 site |
|
||||
| site | FK(Site) | IGNY8 site (contains wp_api_key) |
|
||||
| platform | CharField | 'wordpress' |
|
||||
| platform_type | CharField | 'cms' |
|
||||
| config_json | JSONField | `{ "site_url": "https://..." }` |
|
||||
| credentials_json | JSONField | `{ "api_key": "igny8_xxx" }` |
|
||||
| credentials_json | JSONField | `{ "plugin_version": "1.3.4", "debug_enabled": false }` |
|
||||
| is_active | Boolean | Connection enabled |
|
||||
| sync_enabled | Boolean | Two-way sync enabled |
|
||||
| last_sync_at | DateTime | Last successful sync |
|
||||
| sync_status | CharField | pending/success/failed/syncing |
|
||||
| sync_error | TextField | Last error message |
|
||||
|
||||
### 7.2 SyncEvent
|
||||
**Note:** `credentials_json` no longer stores API key. API key is stored in `Site.wp_api_key` (single source of truth).
|
||||
|
||||
### 7.1a Site Model (API Key Storage)
|
||||
|
||||
| Field | Type | Purpose |
|
||||
|-------|------|---------|-------|
|
||||
| id | AutoField | Primary key |
|
||||
| account | FK(Account) | Owner account |
|
||||
| name | CharField | Site display name |
|
||||
| url | URLField | WordPress site URL |
|
||||
| wp_api_key | CharField | **API key for WordPress integration (SINGLE source of truth)** |
|
||||
| wp_url | URLField | Legacy field (deprecated) |
|
||||
| wp_username | CharField | Legacy field (deprecated) |
|
||||
| wp_app_password | CharField | Legacy field (deprecated) |
|
||||
| hosting_type | CharField | 'wordpress', 'shopify', 'igny8_sites', 'multi' |
|
||||
|
||||
### 7.2 Plugin Models
|
||||
|
||||
#### Plugin
|
||||
|
||||
Core plugin registry (platform-agnostic).
|
||||
|
||||
| Field | Type | Purpose |
|
||||
|-------|------|---------|-------|
|
||||
| id | AutoField | Primary key |
|
||||
| name | CharField | Plugin display name (e.g., "IGNY8 WordPress Bridge") |
|
||||
| slug | SlugField | URL-safe identifier (e.g., "igny8-wp-bridge") |
|
||||
| platform | CharField | Target platform ('wordpress', 'shopify', etc.) |
|
||||
| description | TextField | Plugin description |
|
||||
| author | CharField | Plugin author |
|
||||
| author_url | URLField | Author website |
|
||||
| plugin_url | URLField | Plugin homepage |
|
||||
| icon_url | URLField | Plugin icon (256x256) |
|
||||
| banner_url | URLField | Plugin banner (772x250) |
|
||||
| is_active | Boolean | Whether plugin is available for download |
|
||||
| created_at | DateTime | Record creation |
|
||||
| updated_at | DateTime | Last modified |
|
||||
|
||||
**Current WordPress Plugin:**
|
||||
- Name: "IGNY8 WordPress Bridge"
|
||||
- Slug: "igny8-wp-bridge"
|
||||
- Platform: "wordpress"
|
||||
- Description: "Connect your WordPress site to IGNY8 for AI-powered content publishing, SEO optimization, and seamless automation."
|
||||
- Author: "IGNY8 Team"
|
||||
|
||||
#### PluginVersion
|
||||
|
||||
Version tracking with distribution files.
|
||||
|
||||
| Field | Type | Purpose |
|
||||
|-------|------|---------|-------|
|
||||
| id | AutoField | Primary key |
|
||||
| plugin | FK(Plugin) | Parent plugin |
|
||||
| version | CharField | Semantic version (e.g., "1.3.4") |
|
||||
| status | CharField | 'development', 'beta', 'released', 'deprecated' |
|
||||
| release_notes | TextField | Changelog/release notes |
|
||||
| file_path | CharField | Path to ZIP file in /plugins/{platform}/dist/ |
|
||||
| file_size | BigInteger | ZIP file size in bytes |
|
||||
| checksum_md5 | CharField | MD5 hash for verification |
|
||||
| checksum_sha256 | CharField | SHA256 hash for verification |
|
||||
| requires_version | CharField | Minimum platform version (e.g., WP 5.6+) |
|
||||
| tested_version | CharField | Tested up to version |
|
||||
| is_latest | Boolean | Whether this is the latest stable version |
|
||||
| download_count | Integer | Number of downloads |
|
||||
| released_at | DateTime | Public release date |
|
||||
| created_at | DateTime | Record creation |
|
||||
|
||||
**Current Latest Version (1.3.4):**
|
||||
- Status: "released"
|
||||
- File: `/plugins/wordpress/dist/igny8-wp-bridge-1.3.4.zip`
|
||||
- Requires: WordPress 5.6+
|
||||
- Tested: WordPress 6.4
|
||||
- Features: API key authentication only, template improvements, image layout fixes
|
||||
|
||||
#### PluginInstallation
|
||||
|
||||
Tracks plugin installations per site.
|
||||
|
||||
| Field | Type | Purpose |
|
||||
|-------|------|---------|-------|
|
||||
| id | AutoField | Primary key |
|
||||
| site | FK(Site) | Site where plugin is installed |
|
||||
| plugin_version | FK(PluginVersion) | Installed version |
|
||||
| installed_at | DateTime | Installation timestamp |
|
||||
| last_seen | DateTime | Last health check |
|
||||
| status | CharField | 'active', 'inactive', 'error' |
|
||||
| metadata | JSONField | Installation-specific data (PHP version, WP version, etc.) |
|
||||
|
||||
#### PluginDownload
|
||||
|
||||
Download analytics.
|
||||
|
||||
| Field | Type | Purpose |
|
||||
|-------|------|---------|-------|
|
||||
| id | AutoField | Primary key |
|
||||
| plugin_version | FK(PluginVersion) | Downloaded version |
|
||||
| site | FK(Site, null=True) | Site that downloaded (if authenticated) |
|
||||
| ip_address | GenericIPAddressField | Downloader IP |
|
||||
| user_agent | TextField | Browser/client info |
|
||||
| downloaded_at | DateTime | Download timestamp |
|
||||
|
||||
### 7.3 SyncEvent
|
||||
|
||||
| Field | Type | Purpose |
|
||||
|-------|------|---------|
|
||||
@@ -540,8 +731,7 @@ POST /api/plugins/igny8-wp-bridge/health-check/ - Health monitoring
|
||||
### 8.3 Version History (Recent)
|
||||
|
||||
| Version | Date | Changes |
|
||||
|---------|------|---------|
|
||||
| 1.3.3 | Jan 10, 2026 | Template design: Square image grid fixes, landscape positioning, direct styling for images without captions |
|
||||
|---------|------|---------|| 1.3.4 | Jan 12, 2026 | **API key authentication only** (removed username/password support), webhooks deprecated, Bearer token auth, simplified integration || 1.3.3 | Jan 10, 2026 | Template design: Square image grid fixes, landscape positioning, direct styling for images without captions |
|
||||
| 1.3.2 | Jan 9, 2026 | Template rendering improvements, image layout enhancements |
|
||||
| 1.3.1 | Jan 9, 2026 | Plugin versioning updates |
|
||||
| 1.3.0 | Jan 8, 2026 | Distribution system release, auto-update mechanism |
|
||||
@@ -616,42 +806,69 @@ POST /api/plugins/igny8-wp-bridge/health-check/ - Health monitoring
|
||||
|
||||
## 10. Flow Diagrams
|
||||
|
||||
### 9.1 Integration Setup
|
||||
### 10.1 Integration Setup (API Key Authentication)
|
||||
|
||||
```
|
||||
┌──────────┐ ┌──────────────┐ ┌───────────────┐
|
||||
│ User │ │ IGNY8 App │ │ WordPress │
|
||||
└────┬─────┘ └──────┬───────┘ └───────┬───────┘
|
||||
│ │ │
|
||||
│ 1. Open Site Settings │
|
||||
├─────────────────>│ │
|
||||
│ │ │
|
||||
│ 2. Download Plugin │
|
||||
├─────────────────>│ │
|
||||
│ │ │
|
||||
│<─────────────────┤ │
|
||||
│ 3. Plugin ZIP │ │
|
||||
│ │ │
|
||||
│ 4. Install Plugin──────────────────────┼──────────>
|
||||
│ │ │
|
||||
│ 5. Generate API Key │
|
||||
├─────────────────>│ │
|
||||
│<─────────────────┤ │
|
||||
│ 6. Display API Key │
|
||||
│ │ │
|
||||
│ 7. Enter API Key in Plugin─────────────┼──────────>
|
||||
│ │ │
|
||||
│ 8. Test Connection │
|
||||
├─────────────────>│ │
|
||||
│ │ 9. GET /wp-json/... │
|
||||
│ ├────────────────────>│
|
||||
│ │<────────────────────┤
|
||||
│<─────────────────┤ 10. Success │
|
||||
│ │ │
|
||||
│ │ 11. Register Install│
|
||||
│ │<────────────────────┤
|
||||
┌──────────┐ ┌─────────────────┐ ┌───────────────┐
|
||||
│ User │ │ IGNY8 API │ │ WordPress │
|
||||
│ │ │ (Backend) │ │ Site │
|
||||
└────┬─────┘ └────────┬────────┘ └───────┬───────┘
|
||||
│ │ │
|
||||
│ 1. Generate API Key (Site Settings) │
|
||||
├───────────────────>│ │
|
||||
│ │ Store in Site.wp_api_key
|
||||
│<───────────────────┤ (SINGLE source) │
|
||||
│ 2. API Key: igny8_live_xxxxx │
|
||||
│ │ │
|
||||
│ 3. Download Plugin ZIP │
|
||||
├───────────────────>│ │
|
||||
│ │ GET /api/plugins/ │
|
||||
│ │ igny8-wp-bridge/ │
|
||||
│ │ download/ │
|
||||
│<───────────────────┤ │
|
||||
│ 4. igny8-wp-bridge-1.3.4.zip │
|
||||
│ │ │
|
||||
│ 5. Install & Activate Plugin──────────────┼────────>
|
||||
│ │ │
|
||||
│ 6. Enter API Key + Site ID in WP Settings─┼────────>
|
||||
│ │ │
|
||||
│ 7. Click "Test Connection" in Plugin──────┼────────>
|
||||
│ │ │
|
||||
│ │ 8. POST /api/v1/ │
|
||||
│ │ integration/ │
|
||||
│ │ integrations/ │
|
||||
│ │ test-connection/ │
|
||||
│ │<─────────────────────┤
|
||||
│ │ Headers: │
|
||||
│ │ Authorization: │
|
||||
│ │ Bearer {api_key} │
|
||||
│ │ │
|
||||
│ │ Validate against │
|
||||
│ │ Site.wp_api_key │
|
||||
│ │ │
|
||||
│ │ Create/Update │
|
||||
│ │ SiteIntegration │
|
||||
│ │ (credentials_json={})│
|
||||
│ │ │
|
||||
│ │ 9. 200 OK │
|
||||
│ ├─────────────────────>│
|
||||
│ │ {success: true} │
|
||||
│ │ │
|
||||
│ 10. Success Message in Plugin─────────────┼────────>
|
||||
│ │ │
|
||||
│ │ 11. POST /register/ │
|
||||
│ │<─────────────────────┤
|
||||
│ │ Store PluginInstallation
|
||||
│ │ │
|
||||
```
|
||||
|
||||
**Key Changes in v1.3.4:**
|
||||
- ✅ API key stored in `Site.wp_api_key` (not in SiteIntegration)
|
||||
- ✅ `credentials_json` is empty (only stores plugin_version, debug_enabled)
|
||||
- ✅ Authentication via `Authorization: Bearer {api_key}` header
|
||||
- ✅ No WordPress admin username/password needed
|
||||
- ✅ Simplified setup - single API key for all communication
|
||||
|
||||
### 10.2 Manual Publishing
|
||||
|
||||
```
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* WordPress Integration Form Component
|
||||
* Inline form for WordPress integration with API key generation and plugin download
|
||||
* Simplified - uses only Site.wp_api_key, no SiteIntegration model needed
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card } from '../ui/card';
|
||||
@@ -11,7 +11,6 @@ import Input from '../form/input/InputField';
|
||||
import Checkbox from '../form/input/Checkbox';
|
||||
import Switch from '../form/switch/Switch';
|
||||
import { useToast } from '../ui/toast/ToastContainer';
|
||||
import { integrationApi, SiteIntegration } from '../../services/integration.api';
|
||||
import { fetchAPI, API_BASE_URL } from '../../services/api';
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
@@ -28,18 +27,18 @@ import {
|
||||
|
||||
interface WordPressIntegrationFormProps {
|
||||
siteId: number;
|
||||
integration: SiteIntegration | null;
|
||||
siteName?: string;
|
||||
siteUrl?: string;
|
||||
onIntegrationUpdate?: (integration: SiteIntegration) => void;
|
||||
wpApiKey?: string; // API key from Site.wp_api_key
|
||||
onApiKeyUpdate?: (apiKey: string | null) => void;
|
||||
}
|
||||
|
||||
export default function WordPressIntegrationForm({
|
||||
siteId,
|
||||
integration,
|
||||
siteName,
|
||||
siteUrl,
|
||||
onIntegrationUpdate,
|
||||
wpApiKey,
|
||||
onApiKeyUpdate,
|
||||
}: WordPressIntegrationFormProps) {
|
||||
const toast = useToast();
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -48,15 +47,20 @@ export default function WordPressIntegrationForm({
|
||||
const [apiKeyVisible, setApiKeyVisible] = useState(false);
|
||||
const [pluginInfo, setPluginInfo] = useState<any>(null);
|
||||
const [loadingPlugin, setLoadingPlugin] = useState(false);
|
||||
|
||||
// Connection status state
|
||||
const [connectionStatus, setConnectionStatus] = useState<'unknown' | 'testing' | 'connected' | 'api_key_pending' | 'plugin_missing' | 'error'>('unknown');
|
||||
const [connectionMessage, setConnectionMessage] = useState<string>('');
|
||||
const [testingConnection, setTestingConnection] = useState(false);
|
||||
|
||||
// Load API key from integration on mount or when integration changes
|
||||
// Load API key from wpApiKey prop (from Site.wp_api_key) on mount or when it changes
|
||||
useEffect(() => {
|
||||
if (integration?.api_key) {
|
||||
setApiKey(integration.api_key);
|
||||
if (wpApiKey) {
|
||||
setApiKey(wpApiKey);
|
||||
} else {
|
||||
setApiKey('');
|
||||
}
|
||||
}, [integration]);
|
||||
}, [wpApiKey]);
|
||||
|
||||
// Fetch plugin information
|
||||
useEffect(() => {
|
||||
@@ -75,11 +79,84 @@ export default function WordPressIntegrationForm({
|
||||
fetchPluginInfo();
|
||||
}, []);
|
||||
|
||||
// Test connection when API key exists
|
||||
const testConnection = async () => {
|
||||
if (!apiKey || !siteUrl) {
|
||||
setConnectionStatus('unknown');
|
||||
setConnectionMessage('API key or site URL missing');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setTestingConnection(true);
|
||||
setConnectionStatus('testing');
|
||||
setConnectionMessage('Testing connection...');
|
||||
|
||||
// Call backend to test connection to WordPress
|
||||
// Backend reads API key from Site.wp_api_key (single source of truth)
|
||||
const response = await fetchAPI('/v1/integration/integrations/test-connection/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
site_id: siteId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
// Check the health checks from response
|
||||
const healthChecks = response.health_checks || {};
|
||||
|
||||
// CRITICAL: api_key_verified confirms WordPress accepts our API key
|
||||
if (healthChecks.api_key_verified) {
|
||||
setConnectionStatus('connected');
|
||||
setConnectionMessage('WordPress is connected and API key verified');
|
||||
toast.success('WordPress connection verified!');
|
||||
} else if (healthChecks.plugin_has_api_key && !healthChecks.api_key_verified) {
|
||||
// WordPress has A key, but it's NOT the same as IGNY8's key
|
||||
setConnectionStatus('api_key_pending');
|
||||
setConnectionMessage('API key mismatch - copy the key from IGNY8 to WordPress plugin');
|
||||
toast.warning('WordPress has different API key. Please update WordPress with the key shown above.');
|
||||
} else if (healthChecks.plugin_installed && !healthChecks.plugin_has_api_key) {
|
||||
setConnectionStatus('api_key_pending');
|
||||
setConnectionMessage('Plugin installed - please add API key in WordPress');
|
||||
toast.warning('Plugin found but API key not configured in WordPress');
|
||||
} else if (!healthChecks.plugin_installed) {
|
||||
setConnectionStatus('plugin_missing');
|
||||
setConnectionMessage('IGNY8 plugin not installed on WordPress site');
|
||||
toast.warning('WordPress site reachable but plugin not found');
|
||||
} else {
|
||||
setConnectionStatus('error');
|
||||
setConnectionMessage(response.message || 'Connection verification incomplete');
|
||||
toast.error(response.message || 'Connection test incomplete');
|
||||
}
|
||||
} else {
|
||||
setConnectionStatus('error');
|
||||
setConnectionMessage(response.message || 'Connection test failed');
|
||||
toast.error(response.message || 'Connection test failed');
|
||||
}
|
||||
} catch (error: any) {
|
||||
setConnectionStatus('error');
|
||||
setConnectionMessage(error.message || 'Connection test failed');
|
||||
toast.error(`Connection test failed: ${error.message}`);
|
||||
} finally {
|
||||
setTestingConnection(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-test connection when API key changes
|
||||
useEffect(() => {
|
||||
if (apiKey && siteUrl) {
|
||||
testConnection();
|
||||
} else {
|
||||
setConnectionStatus('unknown');
|
||||
setConnectionMessage('');
|
||||
}
|
||||
}, [apiKey, siteUrl]);
|
||||
|
||||
const handleGenerateApiKey = async () => {
|
||||
try {
|
||||
setGeneratingKey(true);
|
||||
|
||||
// Call the new generate-api-key endpoint
|
||||
// Call the simplified generate-api-key endpoint
|
||||
const response = await fetchAPI('/v1/integration/integrations/generate-api-key/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ site_id: siteId }),
|
||||
@@ -89,9 +166,9 @@ export default function WordPressIntegrationForm({
|
||||
setApiKey(newKey);
|
||||
setApiKeyVisible(true);
|
||||
|
||||
// Trigger integration update
|
||||
if (onIntegrationUpdate && response.integration) {
|
||||
onIntegrationUpdate(response.integration);
|
||||
// Notify parent component
|
||||
if (onApiKeyUpdate) {
|
||||
onApiKeyUpdate(newKey);
|
||||
}
|
||||
|
||||
toast.success('API key generated successfully');
|
||||
@@ -119,9 +196,9 @@ export default function WordPressIntegrationForm({
|
||||
setApiKey(newKey);
|
||||
setApiKeyVisible(true);
|
||||
|
||||
// Trigger integration update
|
||||
if (onIntegrationUpdate && response.integration) {
|
||||
onIntegrationUpdate(response.integration);
|
||||
// Notify parent component
|
||||
if (onApiKeyUpdate) {
|
||||
onApiKeyUpdate(newKey);
|
||||
}
|
||||
|
||||
toast.success('API key regenerated successfully');
|
||||
@@ -139,20 +216,20 @@ export default function WordPressIntegrationForm({
|
||||
try {
|
||||
setGeneratingKey(true);
|
||||
|
||||
if (!integration) {
|
||||
toast.error('No integration found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete the integration to revoke the API key
|
||||
await integrationApi.deleteIntegration(integration.id);
|
||||
// Revoke API key via dedicated endpoint (single source of truth: Site.wp_api_key)
|
||||
await fetchAPI('/v1/integration/integrations/revoke-api-key/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ site_id: siteId }),
|
||||
});
|
||||
|
||||
setApiKey('');
|
||||
setApiKeyVisible(false);
|
||||
setConnectionStatus('unknown');
|
||||
setConnectionMessage('');
|
||||
|
||||
// Trigger integration update
|
||||
if (onIntegrationUpdate) {
|
||||
onIntegrationUpdate(null as any);
|
||||
// Notify parent component
|
||||
if (onApiKeyUpdate) {
|
||||
onApiKeyUpdate(null);
|
||||
}
|
||||
|
||||
toast.success('API key revoked successfully');
|
||||
@@ -183,47 +260,9 @@ export default function WordPressIntegrationForm({
|
||||
return key.substring(0, 8) + '**********' + key.substring(key.length - 4);
|
||||
};
|
||||
|
||||
// Toggle integration sync enabled status (not creation - that happens automatically)
|
||||
const [integrationEnabled, setIntegrationEnabled] = useState(integration?.sync_enabled ?? false);
|
||||
|
||||
const handleToggleIntegration = async (enabled: boolean) => {
|
||||
try {
|
||||
setIntegrationEnabled(enabled);
|
||||
|
||||
if (integration) {
|
||||
// Update existing integration - only toggle sync_enabled, not creation
|
||||
await integrationApi.updateIntegration(integration.id, {
|
||||
sync_enabled: enabled,
|
||||
} as any);
|
||||
toast.success(enabled ? 'Sync enabled' : 'Sync disabled');
|
||||
|
||||
// Reload integration
|
||||
const updated = await integrationApi.getWordPressIntegration(siteId);
|
||||
if (onIntegrationUpdate && updated) {
|
||||
onIntegrationUpdate(updated);
|
||||
}
|
||||
} else {
|
||||
// Integration doesn't exist - it should be created automatically by plugin
|
||||
// when user connects from WordPress side
|
||||
toast.info('Integration will be created automatically when you connect from WordPress plugin. Please connect from the plugin first.');
|
||||
setIntegrationEnabled(false);
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(`Failed to update integration: ${error.message}`);
|
||||
// Revert on error
|
||||
setIntegrationEnabled(!enabled);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (integration) {
|
||||
setIntegrationEnabled(integration.sync_enabled ?? false);
|
||||
}
|
||||
}, [integration]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header with Toggle */}
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
||||
@@ -239,13 +278,60 @@ export default function WordPressIntegrationForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toggle Switch */}
|
||||
{/* Connection Status */}
|
||||
{apiKey && (
|
||||
<Switch
|
||||
label={integrationEnabled ? 'Sync Enabled' : 'Sync Disabled'}
|
||||
checked={integrationEnabled}
|
||||
onChange={(checked) => handleToggleIntegration(checked)}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Status Badge */}
|
||||
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-lg border ${
|
||||
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>
|
||||
|
||||
@@ -264,18 +350,9 @@ export default function WordPressIntegrationForm({
|
||||
onClick={handleGenerateApiKey}
|
||||
variant="solid"
|
||||
disabled={generatingKey}
|
||||
startIcon={generatingKey ? <RefreshCwIcon className="w-4 h-4 animate-spin" /> : <PlusIcon className="w-4 h-4" />}
|
||||
>
|
||||
{generatingKey ? (
|
||||
<>
|
||||
<RefreshCwIcon className="w-4 h-4 mr-2 animate-spin" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PlusIcon className="w-4 h-4 mr-2" />
|
||||
Add API Key
|
||||
</>
|
||||
)}
|
||||
{generatingKey ? 'Generating...' : 'Add API Key'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -306,6 +383,7 @@ export default function WordPressIntegrationForm({
|
||||
readOnly
|
||||
type={apiKeyVisible ? 'text' : 'password'}
|
||||
value={apiKeyVisible ? apiKey : maskApiKey(apiKey)}
|
||||
onChange={() => {}} // No-op to satisfy React
|
||||
/>
|
||||
<IconButton
|
||||
onClick={handleCopyApiKey}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Content } from '../../services/api';
|
||||
import Badge from '../../components/ui/badge/Badge';
|
||||
import { formatRelativeDate } from '../../utils/date';
|
||||
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 {
|
||||
key: string;
|
||||
@@ -48,8 +48,10 @@ export interface ApprovedPageConfig {
|
||||
export function createApprovedPageConfig(params: {
|
||||
searchTerm: string;
|
||||
setSearchTerm: (value: string) => void;
|
||||
publishStatusFilter: string;
|
||||
setPublishStatusFilter: (value: string) => void;
|
||||
statusFilter: string;
|
||||
setStatusFilter: (value: string) => void;
|
||||
siteStatusFilter: string;
|
||||
setSiteStatusFilter: (value: string) => void;
|
||||
setCurrentPage: (page: number) => void;
|
||||
activeSector: { id: number; name: string } | null;
|
||||
onRowClick?: (row: Content) => void;
|
||||
@@ -97,10 +99,12 @@ export function createApprovedPageConfig(params: {
|
||||
sortable: true,
|
||||
sortField: 'status',
|
||||
render: (value: string, row: Content) => {
|
||||
// Map internal status to user-friendly labels
|
||||
// Map internal status to standard labels
|
||||
const statusConfig: Record<string, { color: 'success' | 'blue' | 'amber' | 'gray'; label: string }> = {
|
||||
'approved': { color: 'blue', label: 'Ready to Publish' },
|
||||
'published': { color: 'success', label: row.external_id ? 'On Site' : 'Approved' },
|
||||
'draft': { color: 'gray', label: 'Draft' },
|
||||
'review': { color: 'amber', label: 'Review' },
|
||||
'approved': { color: 'blue', label: 'Approved' },
|
||||
'published': { color: 'success', label: 'Published' },
|
||||
};
|
||||
const config = statusConfig[value] || { color: 'gray' as const, label: value || '-' };
|
||||
|
||||
@@ -112,31 +116,21 @@ export function createApprovedPageConfig(params: {
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'wordpress_status',
|
||||
label: 'Site Content Status',
|
||||
sortable: false,
|
||||
width: '120px',
|
||||
render: (_value: any, row: Content) => {
|
||||
// Check if content has been published to WordPress
|
||||
if (!row.external_id) {
|
||||
return (
|
||||
<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';
|
||||
key: 'site_status',
|
||||
label: 'Site Status',
|
||||
sortable: true,
|
||||
sortField: 'site_status',
|
||||
width: '130px',
|
||||
render: (value: string, row: Content) => {
|
||||
// Show actual site_status field
|
||||
const statusConfig: Record<string, { color: 'success' | 'amber' | 'blue' | 'gray' | 'red'; label: string }> = {
|
||||
publish: { color: 'success', label: 'Published' },
|
||||
draft: { color: 'gray', label: 'Draft' },
|
||||
pending: { color: 'amber', label: 'Pending' },
|
||||
future: { color: 'blue', label: 'Scheduled' },
|
||||
private: { color: 'amber', label: 'Private' },
|
||||
trash: { color: 'red', label: 'Trashed' },
|
||||
'not_published': { color: 'gray', label: 'Not Published' },
|
||||
'scheduled': { color: 'amber', label: 'Scheduled' },
|
||||
'publishing': { color: 'amber', label: 'Publishing' },
|
||||
'published': { color: 'success', label: 'Published' },
|
||||
'failed': { color: 'red', label: 'Failed' },
|
||||
};
|
||||
const config = statusConfig[wpStatus] || { color: 'success' as const, label: 'Published' };
|
||||
const config = statusConfig[value] || { color: 'gray' as const, label: value || 'Not Published' };
|
||||
|
||||
return (
|
||||
<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',
|
||||
@@ -283,13 +299,46 @@ export function createApprovedPageConfig(params: {
|
||||
placeholder: 'Search approved content...',
|
||||
},
|
||||
{
|
||||
key: 'publishStatus',
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: '', label: 'All' },
|
||||
{ value: 'draft', label: 'Draft' },
|
||||
{ value: 'review', label: 'Review' },
|
||||
{ value: 'approved', label: 'Approved' },
|
||||
{ value: 'published', label: 'Published' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'site_status',
|
||||
label: 'Site Status',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: '', label: 'All' },
|
||||
{ value: 'published', label: 'Published to Site' },
|
||||
{ value: 'not_published', label: 'Not Published' },
|
||||
{ value: 'scheduled', label: 'Scheduled' },
|
||||
{ value: 'publishing', label: 'Publishing' },
|
||||
{ value: 'published', label: 'Published' },
|
||||
{ value: 'failed', label: 'Failed' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'content_type',
|
||||
label: 'Type',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: '', label: 'All Types' },
|
||||
...CONTENT_TYPE_OPTIONS,
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'content_structure',
|
||||
label: 'Structure',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: '', label: 'All Structures' },
|
||||
...ALL_CONTENT_STRUCTURES,
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -188,9 +188,21 @@ export const createImagesPageConfig = (
|
||||
type: 'text',
|
||||
placeholder: 'Search by content title...',
|
||||
},
|
||||
{
|
||||
key: 'content_status',
|
||||
label: 'Content Status',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: '', label: 'All' },
|
||||
{ value: 'draft', label: 'Draft' },
|
||||
{ value: 'review', label: 'Review' },
|
||||
{ value: 'approved', label: 'Approved' },
|
||||
{ value: 'published', label: 'Published' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
label: 'Image Status',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: '', label: 'All Status' },
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Content } from '../../services/api';
|
||||
import Badge from '../../components/ui/badge/Badge';
|
||||
import { formatRelativeDate } from '../../utils/date';
|
||||
import { CheckCircleIcon } from '../../icons';
|
||||
import { STRUCTURE_LABELS, TYPE_LABELS } from '../structureMapping';
|
||||
import { STRUCTURE_LABELS, TYPE_LABELS, CONTENT_TYPE_OPTIONS, ALL_CONTENT_STRUCTURES } from '../structureMapping';
|
||||
|
||||
export interface ColumnConfig {
|
||||
key: string;
|
||||
@@ -256,6 +256,49 @@ export function createReviewPageConfig(params: {
|
||||
type: 'text',
|
||||
placeholder: 'Search content...',
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: '', label: 'All' },
|
||||
{ value: 'draft', label: 'Draft' },
|
||||
{ value: 'review', label: 'Review' },
|
||||
{ value: 'approved', label: 'Approved' },
|
||||
{ value: 'published', label: 'Published' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'site_status',
|
||||
label: 'Site Status',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: '', label: 'All' },
|
||||
{ value: 'not_published', label: 'Not Published' },
|
||||
{ value: 'scheduled', label: 'Scheduled' },
|
||||
{ value: 'publishing', label: 'Publishing' },
|
||||
{ value: 'published', label: 'Published' },
|
||||
{ value: 'failed', label: 'Failed' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'content_type',
|
||||
label: 'Type',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: '', label: 'All Types' },
|
||||
...CONTENT_TYPE_OPTIONS,
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'content_structure',
|
||||
label: 'Structure',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ value: '', label: 'All Structures' },
|
||||
...ALL_CONTENT_STRUCTURES,
|
||||
],
|
||||
},
|
||||
],
|
||||
headerMetrics: [
|
||||
{
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
} from '../../services/api';
|
||||
import { useSiteStore } from '../../store/siteStore';
|
||||
import WordPressIntegrationForm from '../../components/sites/WordPressIntegrationForm';
|
||||
import { integrationApi, SiteIntegration } from '../../services/integration.api';
|
||||
import { GridIcon, PlugInIcon, PaperPlaneIcon, DocsIcon, BoltIcon, FileIcon, ChevronDownIcon, CloseIcon, PlusIcon, RefreshCwIcon, FileTextIcon, ImageIcon, SaveIcon, Loader2Icon, ArrowRightIcon, SettingsIcon, GlobeIcon, LayersIcon, CheckCircleIcon, CalendarIcon, InfoIcon } from '../../icons';
|
||||
import Badge from '../../components/ui/badge/Badge';
|
||||
import { Dropdown } from '../../components/ui/dropdown/Dropdown';
|
||||
@@ -45,8 +44,6 @@ export default function SiteSettings() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [site, setSite] = useState<any>(null);
|
||||
const [wordPressIntegration, setWordPressIntegration] = useState<SiteIntegration | null>(null);
|
||||
const [integrationLoading, setIntegrationLoading] = useState(false);
|
||||
|
||||
// Site selector state
|
||||
const [sites, setSites] = useState<Site[]>([]);
|
||||
@@ -134,12 +131,10 @@ export default function SiteSettings() {
|
||||
useEffect(() => {
|
||||
if (siteId) {
|
||||
// Clear state when site changes
|
||||
setWordPressIntegration(null);
|
||||
setSite(null);
|
||||
|
||||
// Load new site data
|
||||
loadSite();
|
||||
loadIntegrations();
|
||||
loadIndustries();
|
||||
}
|
||||
}, [siteId]);
|
||||
@@ -248,17 +243,10 @@ export default function SiteSettings() {
|
||||
}
|
||||
};
|
||||
|
||||
const loadIntegrations = async () => {
|
||||
if (!siteId) return;
|
||||
try {
|
||||
setIntegrationLoading(true);
|
||||
const integration = await integrationApi.getWordPressIntegration(Number(siteId));
|
||||
setWordPressIntegration(integration);
|
||||
} catch (error: any) {
|
||||
// Integration might not exist, that's okay
|
||||
setWordPressIntegration(null);
|
||||
} finally {
|
||||
setIntegrationLoading(false);
|
||||
const handleApiKeyUpdate = (newApiKey: string | null) => {
|
||||
// Update site state with new API key
|
||||
if (site) {
|
||||
setSite({ ...site, wp_api_key: newApiKey });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -495,11 +483,6 @@ export default function SiteSettings() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleIntegrationUpdate = async (integration: SiteIntegration) => {
|
||||
setWordPressIntegration(integration);
|
||||
await loadIntegrations();
|
||||
};
|
||||
|
||||
const formatRelativeTime = (iso: string | null) => {
|
||||
if (!iso) return '-';
|
||||
const then = new Date(iso).getTime();
|
||||
@@ -516,83 +499,56 @@ export default function SiteSettings() {
|
||||
return `${months}mo ago`;
|
||||
};
|
||||
|
||||
// Integration status with authentication check
|
||||
// Integration status - tracks actual connection state
|
||||
const [integrationStatus, setIntegrationStatus] = useState<'connected' | 'configured' | 'not_configured'>('not_configured');
|
||||
const [testingAuth, setTestingAuth] = useState(false);
|
||||
|
||||
// Check basic configuration - integration must exist in DB and have sync_enabled
|
||||
// Check integration status based on API key presence (will be updated by WordPressIntegrationForm)
|
||||
useEffect(() => {
|
||||
const checkStatus = async () => {
|
||||
// Integration must exist in database and have sync_enabled = true
|
||||
if (wordPressIntegration && wordPressIntegration.id && wordPressIntegration.sync_enabled) {
|
||||
setIntegrationStatus('configured');
|
||||
// Test authentication
|
||||
testAuthentication();
|
||||
} else {
|
||||
setIntegrationStatus('not_configured');
|
||||
}
|
||||
};
|
||||
checkStatus();
|
||||
}, [wordPressIntegration, site]);
|
||||
|
||||
// Auto-refresh integration list periodically to detect plugin-created integrations
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (!wordPressIntegration) {
|
||||
loadIntegrations();
|
||||
}
|
||||
}, 5000); // Check every 5 seconds if integration doesn't exist
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [wordPressIntegration]);
|
||||
|
||||
// Test authentication with WordPress API
|
||||
const testAuthentication = async () => {
|
||||
if (testingAuth || !wordPressIntegration?.id) return;
|
||||
|
||||
try {
|
||||
setTestingAuth(true);
|
||||
const resp = await fetchAPI(`/v1/integration/integrations/${wordPressIntegration.id}/test_connection/`, {
|
||||
method: 'POST',
|
||||
body: {}
|
||||
});
|
||||
|
||||
if (resp && resp.success) {
|
||||
setIntegrationStatus('connected');
|
||||
} else {
|
||||
// Keep as 'configured' if auth fails
|
||||
setIntegrationStatus('configured');
|
||||
}
|
||||
} catch (err) {
|
||||
// Keep as 'configured' if auth test fails
|
||||
if (site?.wp_api_key) {
|
||||
// API key exists - mark as configured (actual connection tested in WordPressIntegrationForm)
|
||||
setIntegrationStatus('configured');
|
||||
} finally {
|
||||
setTestingAuth(false);
|
||||
} else {
|
||||
setIntegrationStatus('not_configured');
|
||||
}
|
||||
};
|
||||
}, [site?.wp_api_key]);
|
||||
|
||||
// Sync Now handler extracted
|
||||
// Sync Now handler - tests actual WordPress connection
|
||||
const [syncLoading, setSyncLoading] = useState(false);
|
||||
const [lastSyncTime, setLastSyncTime] = useState<string | null>(null);
|
||||
const handleManualSync = async () => {
|
||||
if (!site?.wp_api_key) {
|
||||
toast.error('WordPress API key not configured. Please generate an API key first.');
|
||||
return;
|
||||
}
|
||||
setSyncLoading(true);
|
||||
try {
|
||||
if (wordPressIntegration && wordPressIntegration.id) {
|
||||
const res = await integrationApi.syncIntegration(wordPressIntegration.id, 'metadata');
|
||||
if (res && res.success) {
|
||||
toast.success('WordPress structure synced successfully');
|
||||
if (res.last_sync_at) {
|
||||
setLastSyncTime(res.last_sync_at);
|
||||
}
|
||||
setTimeout(() => loadContentTypes(), 1500);
|
||||
// Test connection to WordPress using backend test endpoint
|
||||
// Backend reads API key from Site.wp_api_key (single source of truth)
|
||||
const res = await fetchAPI('/v1/integration/integrations/test-connection/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
site_id: siteId,
|
||||
}),
|
||||
});
|
||||
if (res && res.success) {
|
||||
// Check health checks
|
||||
const healthChecks = res.health_checks || {};
|
||||
|
||||
if (healthChecks.plugin_has_api_key) {
|
||||
setIntegrationStatus('connected');
|
||||
toast.success('WordPress connection verified - fully connected!');
|
||||
} else if (healthChecks.plugin_installed) {
|
||||
setIntegrationStatus('configured');
|
||||
toast.warning('Plugin found but API key not configured in WordPress');
|
||||
} else {
|
||||
toast.error(res?.message || 'Sync failed to start');
|
||||
toast.warning('WordPress reachable but IGNY8 plugin not installed');
|
||||
}
|
||||
setLastSyncTime(new Date().toISOString());
|
||||
} else {
|
||||
toast.error('No integration configured. Please configure WordPress integration first.');
|
||||
toast.error(res?.message || 'Connection test failed');
|
||||
}
|
||||
} catch (err: any) {
|
||||
toast.error(`Sync failed: ${err?.message || String(err)}`);
|
||||
toast.error(`Connection test failed: ${err?.message || String(err)}`);
|
||||
} finally {
|
||||
setSyncLoading(false);
|
||||
}
|
||||
@@ -739,7 +695,7 @@ export default function SiteSettings() {
|
||||
/>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||
{integrationStatus === 'connected' && 'Connected'}
|
||||
{integrationStatus === 'configured' && (testingAuth ? 'Testing...' : 'Configured')}
|
||||
{integrationStatus === 'configured' && 'Configured'}
|
||||
{integrationStatus === 'not_configured' && 'Not Configured'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -1874,10 +1830,10 @@ export default function SiteSettings() {
|
||||
{activeTab === 'integrations' && siteId && (
|
||||
<WordPressIntegrationForm
|
||||
siteId={Number(siteId)}
|
||||
integration={wordPressIntegration}
|
||||
siteName={site?.name}
|
||||
siteUrl={site?.domain || site?.wp_url}
|
||||
onIntegrationUpdate={handleIntegrationUpdate}
|
||||
wpApiKey={site?.wp_api_key}
|
||||
onApiKeyUpdate={handleApiKeyUpdate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
ContentListResponse,
|
||||
ContentFilters,
|
||||
fetchAPI,
|
||||
fetchWordPressStatus,
|
||||
deleteContent,
|
||||
bulkDeleteContent,
|
||||
} from '../../services/api';
|
||||
@@ -46,9 +45,12 @@ export default function Approved() {
|
||||
const [totalPublished, setTotalPublished] = useState(0);
|
||||
const [totalImagesCount, setTotalImagesCount] = useState(0);
|
||||
|
||||
// Filter state - default to approved status
|
||||
// Filter state
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [publishStatusFilter, setPublishStatusFilter] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState(''); // Status filter (draft/review/approved/published)
|
||||
const [siteStatusFilter, setSiteStatusFilter] = useState(''); // Site status filter (not_published/scheduled/published/failed)
|
||||
const [contentTypeFilter, setContentTypeFilter] = useState(''); // Content type filter (post/page/product/taxonomy)
|
||||
const [contentStructureFilter, setContentStructureFilter] = useState(''); // Content structure filter
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
|
||||
// Pagination state
|
||||
@@ -99,7 +101,10 @@ export default function Approved() {
|
||||
|
||||
const filters: ContentFilters = {
|
||||
...(searchTerm && { search: searchTerm }),
|
||||
status__in: 'approved,published', // Both approved and published content
|
||||
// Default to approved+published if no status filter selected
|
||||
...(statusFilter ? { status: statusFilter } : { status__in: 'approved,published' }),
|
||||
...(contentTypeFilter && { content_type: contentTypeFilter }),
|
||||
...(contentStructureFilter && { content_structure: contentStructureFilter }),
|
||||
page: currentPage,
|
||||
page_size: pageSize,
|
||||
ordering,
|
||||
@@ -107,34 +112,13 @@ export default function Approved() {
|
||||
|
||||
const data: ContentListResponse = await fetchContent(filters);
|
||||
|
||||
// Client-side filter for WordPress publish status if needed
|
||||
// Client-side filter for site_status if needed (backend may not support this filter yet)
|
||||
let filteredResults = data.results || [];
|
||||
if (publishStatusFilter === 'published') {
|
||||
filteredResults = filteredResults.filter(c => c.external_id);
|
||||
} else if (publishStatusFilter === 'not_published') {
|
||||
filteredResults = filteredResults.filter(c => !c.external_id);
|
||||
if (siteStatusFilter) {
|
||||
filteredResults = filteredResults.filter(c => c.site_status === siteStatusFilter);
|
||||
}
|
||||
|
||||
// Fetch WordPress status for published content
|
||||
const resultsWithWPStatus = await Promise.all(
|
||||
filteredResults.map(async (content) => {
|
||||
if (content.external_id) {
|
||||
try {
|
||||
const wpStatus = await fetchWordPressStatus(content.id);
|
||||
return {
|
||||
...content,
|
||||
wordpress_status: wpStatus.wordpress_status,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(`Failed to fetch WP status for content ${content.id}:`, error);
|
||||
return content;
|
||||
}
|
||||
}
|
||||
return content;
|
||||
})
|
||||
);
|
||||
|
||||
setContent(resultsWithWPStatus);
|
||||
setContent(filteredResults);
|
||||
setTotalCount(data.count || 0);
|
||||
setTotalPages(Math.ceil((data.count || 0) / pageSize));
|
||||
|
||||
@@ -148,7 +132,7 @@ export default function Approved() {
|
||||
setShowContent(true);
|
||||
setLoading(false);
|
||||
}
|
||||
}, [currentPage, publishStatusFilter, sortBy, sortDirection, searchTerm, pageSize, toast]);
|
||||
}, [currentPage, statusFilter, siteStatusFilter, contentTypeFilter, contentStructureFilter, sortBy, sortDirection, searchTerm, pageSize, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
loadContent();
|
||||
@@ -326,15 +310,17 @@ export default function Approved() {
|
||||
return createApprovedPageConfig({
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
publishStatusFilter,
|
||||
setPublishStatusFilter,
|
||||
statusFilter,
|
||||
setStatusFilter,
|
||||
siteStatusFilter,
|
||||
setSiteStatusFilter,
|
||||
setCurrentPage,
|
||||
activeSector,
|
||||
onRowClick: (row: Content) => {
|
||||
navigate(`/writer/content/${row.id}`);
|
||||
},
|
||||
});
|
||||
}, [searchTerm, publishStatusFilter, activeSector, navigate]);
|
||||
}, [searchTerm, statusFilter, siteStatusFilter, contentTypeFilter, contentStructureFilter, activeSector, navigate]);
|
||||
|
||||
// Calculate header metrics - use totals from API calls (not page data)
|
||||
// This ensures metrics show correct totals across all pages, not just current page
|
||||
@@ -392,7 +378,10 @@ export default function Approved() {
|
||||
filters={pageConfig.filters}
|
||||
filterValues={{
|
||||
search: searchTerm,
|
||||
publishStatus: publishStatusFilter,
|
||||
status: statusFilter,
|
||||
site_status: siteStatusFilter,
|
||||
content_type: contentTypeFilter,
|
||||
content_structure: contentStructureFilter,
|
||||
}}
|
||||
primaryAction={{
|
||||
label: 'Publish to Site',
|
||||
@@ -403,8 +392,17 @@ export default function Approved() {
|
||||
onFilterChange={(key: string, value: any) => {
|
||||
if (key === 'search') {
|
||||
setSearchTerm(value);
|
||||
} else if (key === 'publishStatus') {
|
||||
setPublishStatusFilter(value);
|
||||
} else if (key === 'status') {
|
||||
setStatusFilter(value);
|
||||
setCurrentPage(1);
|
||||
} else if (key === 'site_status') {
|
||||
setSiteStatusFilter(value);
|
||||
setCurrentPage(1);
|
||||
} else if (key === 'content_type') {
|
||||
setContentTypeFilter(value);
|
||||
setCurrentPage(1);
|
||||
} else if (key === 'content_structure') {
|
||||
setContentStructureFilter(value);
|
||||
setCurrentPage(1);
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -25,6 +25,33 @@ export function formatRelativeDate(dateString: string | Date): string {
|
||||
const diffTime = today.getTime() - dateOnly.getTime();
|
||||
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
// Handle future dates (negative diffDays)
|
||||
if (diffDays < 0) {
|
||||
const futureDays = Math.abs(diffDays);
|
||||
if (futureDays === 1) {
|
||||
return 'Tomorrow';
|
||||
} else if (futureDays < 30) {
|
||||
return `in ${futureDays} days`;
|
||||
} else if (futureDays < 365) {
|
||||
const months = Math.floor(futureDays / 30);
|
||||
const remainingDays = futureDays % 30;
|
||||
if (remainingDays === 0) {
|
||||
return `in ${months} month${months > 1 ? 's' : ''}`;
|
||||
} else {
|
||||
return `in ${months} month${months > 1 ? 's' : ''} ${remainingDays} day${remainingDays > 1 ? 's' : ''}`;
|
||||
}
|
||||
} else {
|
||||
const years = Math.floor(futureDays / 365);
|
||||
const remainingMonths = Math.floor((futureDays % 365) / 30);
|
||||
if (remainingMonths === 0) {
|
||||
return `in ${years} year${years > 1 ? 's' : ''}`;
|
||||
} else {
|
||||
return `in ${years} year${years > 1 ? 's' : ''} ${remainingMonths} month${remainingMonths > 1 ? 's' : ''}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle past dates (positive diffDays)
|
||||
if (diffDays === 0) {
|
||||
return 'Today';
|
||||
} else if (diffDays === 1) {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Plugin Name: IGNY8 WordPress Bridge
|
||||
* Plugin URI: https://igny8.com/igny8-wp-bridge
|
||||
* Description: Lightweight bridge plugin that connects WordPress to IGNY8 API for one-way content publishing.
|
||||
* Version: 1.3.4
|
||||
* Version: 1.3.8
|
||||
* Author: IGNY8
|
||||
* Author URI: https://igny8.com/
|
||||
* License: GPL v2 or later
|
||||
@@ -22,7 +22,7 @@ if (!defined('ABSPATH')) {
|
||||
}
|
||||
|
||||
// Define plugin constants
|
||||
define('IGNY8_BRIDGE_VERSION', '1.3.4');
|
||||
define('IGNY8_BRIDGE_VERSION', '1.3.8');
|
||||
define('IGNY8_BRIDGE_PLUGIN_DIR', plugin_dir_path(__FILE__));
|
||||
define('IGNY8_BRIDGE_PLUGIN_URL', plugin_dir_url(__FILE__));
|
||||
define('IGNY8_BRIDGE_PLUGIN_FILE', __FILE__);
|
||||
|
||||
@@ -85,6 +85,14 @@ class Igny8RestAPI {
|
||||
'permission_callback' => '__return_true', // Public endpoint for health checks
|
||||
));
|
||||
|
||||
// API key verification endpoint - requires valid API key in header
|
||||
// Used by IGNY8 to verify the API keys match
|
||||
register_rest_route('igny8/v1', '/verify-key', array(
|
||||
'methods' => 'GET',
|
||||
'callback' => array($this, 'verify_api_key'),
|
||||
'permission_callback' => array($this, 'check_permission'),
|
||||
));
|
||||
|
||||
// Manual publish endpoint - for triggering WordPress publish from IGNY8
|
||||
// Route: /wp-json/igny8/v1/publish
|
||||
register_rest_route('igny8/v1', '/publish', array(
|
||||
@@ -406,6 +414,28 @@ class Igny8RestAPI {
|
||||
return $this->build_unified_response(true, $data, 'Plugin status retrieved', null, null, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /verify-key - Verify API key is valid and matches
|
||||
* This endpoint requires authentication - if we get here, the API key is valid
|
||||
*
|
||||
* @param WP_REST_Request $request
|
||||
* @return WP_REST_Response
|
||||
*/
|
||||
public function verify_api_key($request) {
|
||||
// If we reach here, check_permission passed, meaning API key is valid
|
||||
$api_key = function_exists('igny8_get_secure_option') ? igny8_get_secure_option('igny8_api_key') : get_option('igny8_api_key');
|
||||
$site_id = get_option('igny8_site_id', '');
|
||||
|
||||
$data = array(
|
||||
'verified' => true,
|
||||
'site_id' => $site_id,
|
||||
'plugin_version' => defined('IGNY8_BRIDGE_VERSION') ? IGNY8_BRIDGE_VERSION : '1.0.0',
|
||||
'api_key_prefix' => !empty($api_key) ? substr($api_key, 0, 15) . '...' : null,
|
||||
);
|
||||
|
||||
return $this->build_unified_response(true, $data, 'API key verified successfully', null, null, 200);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /site-metadata/ - returns post types, taxonomies and counts in unified format
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user