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