fixing issues of integration with wordpress plugin

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

View File

@@ -78,6 +78,7 @@ class SiteSerializer(serializers.ModelSerializer):
'industry', 'industry_name', 'industry_slug',
'is_active', 'status',
'site_type', 'hosting_type', 'seo_metadata',
'wp_api_key', # WordPress API key (single source of truth for integration)
'sectors_count', 'active_sectors_count', 'selected_sectors',
'can_add_sectors', 'keywords_count', 'has_integration',
'created_at', 'updated_at'
@@ -86,6 +87,7 @@ class SiteSerializer(serializers.ModelSerializer):
# Explicitly specify required fields for clarity
extra_kwargs = {
'industry': {'required': True, 'error_messages': {'required': 'Industry is required when creating a site.'}},
'wp_api_key': {'read_only': True}, # Only set via generate-api-key endpoint
}
def __init__(self, *args, **kwargs):

View File

@@ -217,6 +217,7 @@ class IntegrationService:
dict: Connection test result with detailed health status
"""
import requests
from django.utils import timezone
config = integration.config_json
@@ -324,13 +325,6 @@ class IntegrationService:
health_checks['plugin_has_api_key']
)
# Save site_url to config if successful and not already set
if is_healthy and not config.get('site_url'):
config['site_url'] = site_url
integration.config_json = config
integration.save(update_fields=['config_json'])
logger.info(f"[IntegrationService] Saved site_url to integration {integration.id} config: {site_url}")
# Build response message
if is_healthy:
message = "✅ WordPress integration is connected and authenticated via API key"
@@ -347,6 +341,28 @@ class IntegrationService:
else:
message = "❌ WordPress connection failed"
# Update integration status based on connection test result
if is_healthy:
integration.sync_status = 'success'
integration.sync_error = None
integration.last_sync_at = timezone.now()
logger.info(f"[IntegrationService] Connection test passed, set sync_status to 'success' for integration {integration.id}")
else:
integration.sync_status = 'failed'
integration.sync_error = message
logger.warning(f"[IntegrationService] Connection test failed, set sync_status to 'failed' for integration {integration.id}")
# Save site_url to config if successful and not already set
if is_healthy and not config.get('site_url'):
config['site_url'] = site_url
integration.config_json = config
# Save all changes to integration
integration.save()
if is_healthy and not config.get('site_url'):
logger.info(f"[IntegrationService] Saved site_url to integration {integration.id} config: {site_url}")
return {
'success': is_healthy,
'fully_functional': is_healthy,

View File

@@ -127,33 +127,22 @@ class PublisherService:
# Get destination config
destination_config = {}
# If content has site, try to get integration config
# Get WordPress config directly from Site model (no SiteIntegration needed)
if hasattr(content, 'site') and content.site:
from igny8_core.business.integration.models import SiteIntegration
logger.info(f"[PublisherService._publish_to_destination] 🔍 Looking for integration: site={content.site.name}, platform={destination}")
integration = SiteIntegration.objects.filter(
site=content.site,
platform=destination,
is_active=True
).first()
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 {})
site = content.site
logger.info(f"[PublisherService._publish_to_destination] 🔍 Getting config from site: {site.name}")
# API key is stored in Site.wp_api_key (SINGLE source of truth)
if integration.site.wp_api_key:
destination_config['api_key'] = integration.site.wp_api_key
logger.info(f"[PublisherService._publish_to_destination] 🔑 Config merged: has_api_key={bool(destination_config.get('api_key'))}, has_site_url={bool(destination_config.get('site_url'))}")
# Ensure site_url is set (from config or from site model)
if not destination_config.get('site_url'):
destination_config['site_url'] = content.site.url
logger.info(f"[PublisherService._publish_to_destination] 🌐 Using site.url: {content.site.url}")
if site.wp_api_key:
destination_config['api_key'] = site.wp_api_key
logger.info(f"[PublisherService._publish_to_destination] 🔑 API key found on site")
else:
logger.warning(f"[PublisherService._publish_to_destination] ⚠️ No integration found for site={content.site.name}, platform={destination}")
logger.error(f"[PublisherService._publish_to_destination] No API key found on site {site.name}")
raise ValueError(f"WordPress API key not configured for site {site.name}. Please generate an API key in Site Settings.")
# Use Site.domain as site_url
destination_config['site_url'] = site.domain or site.url
logger.info(f"[PublisherService._publish_to_destination] 🌐 Using site URL: {destination_config['site_url']}")
# Publish via adapter
logger.info(f"[PublisherService._publish_to_destination] 🚀 Calling adapter.publish() with config keys: {list(destination_config.keys())}")

View File

@@ -12,6 +12,7 @@ from igny8_core.api.base import SiteSectorModelViewSet
from igny8_core.api.permissions import IsAuthenticatedAndActive, IsEditorOrAbove
from igny8_core.api.response import success_response, error_response
from igny8_core.api.throttles import DebugScopedRateThrottle
from igny8_core.auth.models import Site
from igny8_core.business.integration.models import SiteIntegration
from igny8_core.business.integration.services.integration_service import IntegrationService
from igny8_core.business.integration.services.sync_service import SyncService
@@ -131,19 +132,23 @@ class IntegrationViewSet(SiteSectorModelViewSet):
permission_classes=[AllowAny], throttle_classes=[NoThrottle])
def test_connection_collection(self, request):
"""
Collection-level test connection endpoint for frontend convenience.
Test WordPress connection using Site.wp_api_key (single source of truth).
POST /api/v1/integration/integrations/test-connection/
Body:
{
"site_id": 123,
"api_key": "...",
"site_url": "https://example.com"
"site_id": 123
}
Tests:
1. WordPress site is reachable
2. IGNY8 plugin is installed
3. Plugin has API key configured (matching Site.wp_api_key)
"""
import requests as http_requests
site_id = request.data.get('site_id')
api_key = request.data.get('api_key')
site_url = request.data.get('site_url')
if not site_id:
return error_response('site_id is required', None, status.HTTP_400_BAD_REQUEST, request)
@@ -155,80 +160,146 @@ class IntegrationViewSet(SiteSectorModelViewSet):
except (Site.DoesNotExist, ValueError, TypeError):
return error_response('Site not found or invalid', None, status.HTTP_404_NOT_FOUND, request)
# Authentication: accept either authenticated user OR matching API key in body
api_key = request.data.get('api_key') or api_key
authenticated = False
# If request has a valid user and belongs to same account, allow
if hasattr(request, 'user') and getattr(request.user, 'is_authenticated', False):
# Authentication: user must be authenticated and belong to same account
if not hasattr(request, 'user') or not getattr(request.user, 'is_authenticated', False):
return error_response('Authentication required', None, status.HTTP_403_FORBIDDEN, request)
try:
# If user has account, ensure site belongs to user's account
if site.account == request.user.account:
authenticated = True
if site.account != request.user.account:
return error_response('Site does not belong to your account', None, status.HTTP_403_FORBIDDEN, request)
except Exception:
# Ignore and fallback to api_key check
pass
return error_response('Authentication failed', None, status.HTTP_403_FORBIDDEN, request)
# If not authenticated via session, allow if provided api_key matches site's stored wp_api_key
if not authenticated:
stored_key = getattr(site, 'wp_api_key', None)
if stored_key and api_key and str(api_key) == str(stored_key):
authenticated = True
elif not stored_key:
# API key not set on site - provide helpful error message
# Get stored API key from Site model (single source of truth)
stored_api_key = site.wp_api_key
if not stored_api_key:
return error_response(
'API key not configured 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,
status.HTTP_403_FORBIDDEN,
request
)
elif api_key and stored_key and str(api_key) != str(stored_key):
# API key provided but doesn't match
return error_response(
'Invalid API key. The provided API key does not match the one stored for this site.',
None,
status.HTTP_403_FORBIDDEN,
status.HTTP_400_BAD_REQUEST,
request
)
if not authenticated:
return error_response('Authentication credentials were not provided.', None, status.HTTP_403_FORBIDDEN, request)
# Try to find an existing integration for this site+platform
integration = SiteIntegration.objects.filter(site=site, platform='wordpress').first()
# If not found, create and save the integration to database (for status tracking, not credentials)
integration_created = False
if not integration:
integration = SiteIntegration.objects.create(
account=site.account,
site=site,
platform='wordpress',
platform_type='cms',
config_json={'site_url': site_url} if site_url else {},
credentials_json={}, # API key is stored in Site.wp_api_key, not here
is_active=True,
sync_enabled=True
# Get site URL
site_url = site.domain or site.url
if not site_url:
return error_response(
'Site URL not configured',
None,
status.HTTP_400_BAD_REQUEST,
request
)
integration_created = True
logger.info(f"[IntegrationViewSet] Created WordPress integration {integration.id} for site {site.id}")
service = IntegrationService()
# Mark this as initial connection test since API key was provided in request body
# This allows the test to pass even if WordPress plugin hasn't stored the key yet
is_initial_connection = bool(api_key and request.data.get('api_key'))
result = service._test_wordpress_connection(integration, is_initial_connection=is_initial_connection)
# Health check results
health_checks = {
'site_url_configured': True,
'api_key_configured': True,
'wp_rest_api_reachable': False,
'plugin_installed': False,
'plugin_has_api_key': False,
'api_key_verified': False, # NEW: Verifies IGNY8 and WordPress have SAME key
}
issues = []
if result.get('success'):
# Include integration_id in response so plugin can store it
result['integration_id'] = integration.id
result['integration_created'] = integration_created
return success_response(result, request=request)
try:
# Check 1: WordPress REST API reachable
try:
rest_response = http_requests.get(
f"{site_url.rstrip('/')}/wp-json/",
timeout=10
)
if rest_response.status_code == 200:
health_checks['wp_rest_api_reachable'] = True
else:
# If test failed and we just created integration, delete it
if integration_created:
integration.delete()
logger.info(f"[IntegrationViewSet] Deleted integration {integration.id} due to failed connection test")
return error_response(result.get('message', 'Connection test failed'), None, status.HTTP_400_BAD_REQUEST, request)
issues.append(f"WordPress REST API not reachable: HTTP {rest_response.status_code}")
except Exception as e:
issues.append(f"WordPress REST API unreachable: {str(e)}")
# Check 2: IGNY8 Plugin installed (public status endpoint)
try:
status_response = http_requests.get(
f"{site_url.rstrip('/')}/wp-json/igny8/v1/status",
timeout=10
)
if status_response.status_code == 200:
health_checks['plugin_installed'] = True
status_data = status_response.json()
plugin_data = status_data.get('data', status_data)
if plugin_data.get('connected') or plugin_data.get('has_api_key'):
health_checks['plugin_has_api_key'] = True
else:
issues.append("Plugin installed but no API key configured in WordPress")
else:
issues.append(f"IGNY8 plugin not found: HTTP {status_response.status_code}")
except Exception as e:
issues.append(f"Cannot detect IGNY8 plugin: {str(e)}")
# Check 3: Verify API keys MATCH by making authenticated request
# This is the CRITICAL check - WordPress must accept our API key
if health_checks['plugin_installed'] and health_checks['plugin_has_api_key']:
try:
# Make authenticated request using Site.wp_api_key to dedicated verify endpoint
verify_response = http_requests.get(
f"{site_url.rstrip('/')}/wp-json/igny8/v1/verify-key",
headers={
'X-IGNY8-API-KEY': stored_api_key,
'Content-Type': 'application/json'
},
timeout=10
)
if verify_response.status_code == 200:
health_checks['api_key_verified'] = True
elif verify_response.status_code in [401, 403]:
issues.append("API key mismatch - WordPress has different key than IGNY8. Please copy the API key from IGNY8 to WordPress plugin settings.")
else:
issues.append(f"API key verification failed: HTTP {verify_response.status_code}")
except Exception as e:
issues.append(f"API key verification request failed: {str(e)}")
# Determine overall status - MUST include api_key_verified for true connection
is_healthy = (
health_checks['api_key_configured'] and
health_checks['wp_rest_api_reachable'] and
health_checks['plugin_installed'] and
health_checks['plugin_has_api_key'] and
health_checks['api_key_verified'] # CRITICAL: keys must match
)
# Build message with clear guidance
if is_healthy:
message = "✅ WordPress integration is fully connected and verified"
elif not health_checks['wp_rest_api_reachable']:
message = "❌ Cannot reach WordPress site"
elif not health_checks['plugin_installed']:
message = "⚠️ WordPress is reachable but IGNY8 plugin not installed"
elif not health_checks['plugin_has_api_key']:
message = "⚠️ Plugin installed but no API key configured in WordPress"
elif not health_checks['api_key_verified']:
message = "⚠️ API key mismatch - copy the API key from IGNY8 to WordPress plugin"
else:
message = "❌ WordPress connection failed"
return success_response({
'success': is_healthy,
'message': message,
'site_id': site.id,
'site_name': site.name,
'site_url': site_url,
'api_key_configured': bool(stored_api_key),
'health_checks': health_checks,
'issues': issues if issues else None,
}, request=request)
except Exception as e:
logger.error(f"WordPress connection test failed: {e}")
return error_response(
f'Connection test failed: {str(e)}',
None,
status.HTTP_500_INTERNAL_SERVER_ERROR,
request
)
@extend_schema(tags=['Integration'])
@action(detail=True, methods=['post'])
@@ -808,42 +879,71 @@ class IntegrationViewSet(SiteSectorModelViewSet):
site.wp_api_key = api_key
site.save(update_fields=['wp_api_key'])
# Get or create SiteIntegration (for integration status/config, NOT credentials)
integration, created = SiteIntegration.objects.get_or_create(
site=site,
platform='wordpress',
defaults={
'account': site.account,
'platform': 'wordpress',
'platform_type': 'cms',
'is_active': True,
'sync_enabled': True,
'credentials_json': {}, # Empty - API key is on Site model
'config_json': {}
}
)
# If integration already exists, just ensure it's active
if not created:
integration.is_active = True
integration.sync_enabled = True
# Clear any old credentials_json API key (migrate to Site.wp_api_key)
if integration.credentials_json.get('api_key'):
integration.credentials_json = {}
integration.save()
logger.info(
f"Generated new API key for site {site.name} (ID: {site_id}), "
f"stored in Site.wp_api_key (single source of truth)"
)
# Serialize the integration with the new key
serializer = self.get_serializer(integration)
return success_response({
'api_key': api_key,
'site_id': site.id,
'site_name': site.name,
'site_url': site.domain or site.url,
'message': 'API key generated successfully. WordPress integration is ready.',
}, request=request)
@action(detail=False, methods=['post'], url_path='revoke-api-key')
def revoke_api_key(self, request):
"""
Revoke (delete) the API key for a site's WordPress integration.
POST /api/v1/integration/integrations/revoke-api-key/
Body:
{
"site_id": 5
}
"""
site_id = request.data.get('site_id')
if not site_id:
return error_response(
'Site ID is required',
None,
status.HTTP_400_BAD_REQUEST,
request
)
try:
site = Site.objects.get(id=site_id)
except Site.DoesNotExist:
return error_response(
f'Site with ID {site_id} not found',
None,
status.HTTP_404_NOT_FOUND,
request
)
# Verify user has access to this site
if site.account != request.user.account:
return error_response(
'You do not have permission to modify this site',
None,
status.HTTP_403_FORBIDDEN,
request
)
# SINGLE SOURCE OF TRUTH: Remove API key from Site.wp_api_key
site.wp_api_key = None
site.save(update_fields=['wp_api_key'])
logger.info(
f"Revoked API key for site {site.name} (ID: {site_id})"
)
return success_response({
'integration': serializer.data,
'api_key': api_key,
'message': f"API key {'generated' if created else 'regenerated'} successfully",
'site_id': site.id,
'site_name': site.name,
'message': 'API key revoked successfully. WordPress integration is now disconnected.',
}, request=request)

View File

@@ -429,11 +429,11 @@ class ContentResource(resources.ModelResource):
@admin.register(Content)
class ContentAdmin(ImportExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
resource_class = ContentResource
list_display = ['title', 'content_type', 'content_structure', 'site', 'sector', 'source', 'status', 'word_count', 'get_taxonomy_count', 'created_at']
list_filter = ['status', 'content_type', 'content_structure', 'source', 'site', 'sector', 'cluster', 'word_count', 'created_at']
list_display = ['title', 'content_type', 'content_structure', 'site', 'sector', 'source', 'status', 'site_status', 'scheduled_publish_at', 'word_count', 'get_taxonomy_count', 'created_at']
list_filter = ['status', 'site_status', 'content_type', 'content_structure', 'source', 'site', 'sector', 'cluster', 'word_count', 'created_at']
search_fields = ['title', 'content_html', 'external_url', 'meta_title', 'primary_keyword']
ordering = ['-created_at']
readonly_fields = ['created_at', 'updated_at', 'word_count', 'get_tags_display', 'get_categories_display']
readonly_fields = ['created_at', 'updated_at', 'word_count', 'site_status_updated_at', 'get_tags_display', 'get_categories_display']
autocomplete_fields = ['cluster', 'site', 'sector']
inlines = [ContentTaxonomyInline]
actions = [
@@ -449,6 +449,10 @@ class ContentAdmin(ImportExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
('Basic Info', {
'fields': ('title', 'site', 'sector', 'cluster', 'status')
}),
('Publishing Status', {
'fields': ('site_status', 'scheduled_publish_at', 'site_status_updated_at'),
'description': 'WordPress/external site publishing status. Managed by automated publishing scheduler.'
}),
('Content Classification', {
'fields': ('content_type', 'content_structure', 'source')
}),

View File

@@ -0,0 +1,97 @@
# Generated manually on 2026-01-12
from django.db import migrations
def add_wordpress_plugin(apps, schema_editor):
"""
Add the IGNY8 WordPress Bridge plugin to the database.
"""
Plugin = apps.get_model('plugins', 'Plugin')
PluginVersion = apps.get_model('plugins', 'PluginVersion')
# Create or update the WordPress plugin
plugin, created = Plugin.objects.get_or_create(
slug='igny8-wp-bridge',
defaults={
'name': 'IGNY8 WordPress Bridge',
'platform': 'wordpress',
'description': 'Connect your WordPress site to IGNY8 for AI-powered content publishing, SEO optimization, and seamless automation. Features API key authentication, automated updates, advanced template rendering, and webhook sync.',
'homepage_url': 'https://igny8.com/docs/wordpress-integration',
'is_active': True,
}
)
if not created:
# Update existing plugin with latest information
plugin.name = 'IGNY8 WordPress Bridge'
plugin.description = 'Connect your WordPress site to IGNY8 for AI-powered content publishing, SEO optimization, and seamless automation. Features API key authentication, automated updates, advanced template rendering, and webhook sync.'
plugin.homepage_url = 'https://igny8.com/docs/wordpress-integration'
plugin.is_active = True
plugin.save()
# Add current version (1.3.4) if it doesn't exist
version, created = PluginVersion.objects.get_or_create(
plugin=plugin,
version='1.3.4',
defaults={
'version_code': 10304, # 1.03.04
'status': 'released',
'changelog': '''## Version 1.3.4 (January 12, 2026)
### Major Changes
- **API Key Authentication Only**: Removed username/password authentication
- **Simplified Integration**: Single API key for all communication
- **Bearer Token Auth**: Uses `Authorization: Bearer {api_key}` header
- **Webhooks Deprecated**: Removed webhook signature validation
### Authentication
- API key stored in WordPress options table: `igny8_api_key`
- Accepts both `X-IGNY8-API-KEY` header and `Authorization: Bearer` header
- Single source of truth: Site.wp_api_key in IGNY8 backend
### Technical Improvements
- Streamlined connection test endpoint
- Improved error handling and validation
- Better health check reporting
- Enhanced auto-update mechanism
### Backward Compatibility
- Legacy username/password fields removed
- No migration needed for existing installations
- API key authentication works with all IGNY8 API versions 1.0+
''',
'min_api_version': '1.0',
'min_platform_version': '5.6',
'min_php_version': '7.4',
'file_path': 'plugins/wordpress/dist/igny8-wp-bridge-1.3.4.zip',
'file_size': 0, # Will be updated when file is generated
'checksum': '', # Will be updated when file is generated
'force_update': False,
}
)
if created:
print(f"✅ Created WordPress plugin version 1.3.4")
else:
print(f" WordPress plugin version 1.3.4 already exists")
def remove_wordpress_plugin(apps, schema_editor):
"""
Reverse migration - remove the WordPress plugin.
This is optional and can be commented out if you want to keep the data.
"""
Plugin = apps.get_model('plugins', 'Plugin')
Plugin.objects.filter(slug='igny8-wp-bridge').delete()
class Migration(migrations.Migration):
dependencies = [
('plugins', '0003_simplify_status_choices'),
]
operations = [
migrations.RunPython(add_wordpress_plugin, remove_wordpress_plugin),
]

View File

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

View File

@@ -0,0 +1,371 @@
# Scheduled Content Publishing Workflow
**Last Updated:** January 12, 2026
**Module:** Publishing / Automation
---
## Overview
IGNY8 provides automated content publishing to WordPress sites. Content goes through a scheduling process before being published at the designated time.
---
## Content Lifecycle for Publishing
### Understanding Content.status vs Content.site_status
Content has **TWO separate status fields**:
1. **`status`** - Editorial workflow status
- `draft` - Being created/edited
- `review` - Submitted for review
- `approved` - Ready for publishing
- `published` - Legacy (not used for external publishing)
2. **`site_status`** - External site publishing status
- `not_published` - Not yet published to WordPress
- `scheduled` - Has a scheduled_publish_at time
- `publishing` - Currently being published
- `published` - Successfully published to WordPress
- `failed` - Publishing failed
### Publishing Flow
```
┌─────────────────┐
│ DRAFT │ ← Content is being created/edited
│ status: draft │
└────────┬────────┘
│ User approves content
┌─────────────────┐
│ APPROVED │ ← Content is ready for publishing
│ status: approved│ status='approved', site_status='not_published'
│ site_status: │
│ not_published │
└────────┬────────┘
│ Hourly: schedule_approved_content task
┌─────────────────┐
│ SCHEDULED │ ← Content has a scheduled_publish_at time
│ status: approved│ site_status='scheduled'
│ site_status: │ scheduled_publish_at set to future datetime
│ scheduled │
└────────┬────────┘
│ Every 5 min: process_scheduled_publications task
│ (when scheduled_publish_at <= now)
┌─────────────────┐
│ PUBLISHING │ ← WordPress API call in progress
│ status: approved│ site_status='publishing'
│ site_status: │
│ publishing │
└────────┬────────┘
┌────┴────┐
│ │
▼ ▼
┌────────┐ ┌────────┐
│PUBLISHED│ │ FAILED │
│status: │ │status: │
│approved │ │approved│
│site_ │ │site_ │
│status: │ │status: │
│published│ │failed │
└─────────┘ └────────┘
```
---
## Celery Tasks
### 1. `schedule_approved_content`
**Schedule:** Every hour at :00
**Task Name:** `publishing.schedule_approved_content`
**File:** `backend/igny8_core/tasks/publishing_scheduler.py`
#### What It Does:
1. Finds all sites with `PublishingSettings.auto_publish_enabled = True`
2. Gets approved content (`status='approved'`, `site_status='not_published'`, `scheduled_publish_at=null`)
3. Calculates available publishing slots based on:
- `publish_days` - which days are allowed (e.g., Mon-Fri)
- `publish_time_slots` - which times are allowed (e.g., 09:00, 14:00, 18:00)
- `daily_publish_limit` - max posts per day
- `weekly_publish_limit` - max posts per week
- `monthly_publish_limit` - max posts per month
4. Assigns `scheduled_publish_at` datetime and sets `site_status='scheduled'`
#### Configuration Location:
`PublishingSettings` model linked to each Site. Configurable via:
- Admin: `/admin/integration/publishingsettings/`
- API: `/api/v1/sites/{site_id}/publishing-settings/`
---
### 2. `process_scheduled_publications`
**Schedule:** Every 5 minutes
**Task Name:** `publishing.process_scheduled_publications`
**File:** `backend/igny8_core/tasks/publishing_scheduler.py`
#### What It Does:
1. Finds all content where:
- `site_status='scheduled'`
- `scheduled_publish_at <= now`
2. For each content item:
- Updates `site_status='publishing'`
- Gets the site's WordPress integration
- Queues `publish_content_to_wordpress` Celery task
3. Logs results and any errors
---
### 3. `publish_content_to_wordpress`
**Type:** On-demand Celery task (queued by `process_scheduled_publications`)
**Task Name:** `publishing.publish_content_to_wordpress`
**File:** `backend/igny8_core/tasks/wordpress_publishing.py`
#### What It Does:
1. **Load Content & Integration** - Gets content and WordPress credentials
2. **Check Already Published** - Skips if `external_id` exists
3. **Generate Excerpt** - Creates excerpt from HTML content
4. **Get Taxonomy Terms** - Loads categories and tags from `ContentTaxonomy`
5. **Get Images** - Loads featured image and gallery images
6. **Build API Payload** - Constructs WordPress REST API payload
7. **Call WordPress API** - POSTs to WordPress via IGNY8 Bridge plugin
8. **Update Content** - Sets `external_id`, `external_url`, `site_status='published'`
9. **Log Sync Event** - Records in `SyncEvent` model
#### WordPress Connection:
- Uses the IGNY8 WordPress Bridge plugin installed on the site
- API endpoint: `{site_url}/wp-json/igny8-bridge/v1/publish`
- Authentication: API key stored in `Site.wp_api_key`
---
## Database Models
### Content Fields (Publishing Related)
| Field | Type | Description |
|-------|------|-------------|
| `status` | CharField | **Editorial workflow**: `draft`, `review`, `approved` |
| `site_status` | CharField | **WordPress publishing status**: `not_published`, `scheduled`, `publishing`, `published`, `failed` |
| `site_status_updated_at` | DateTimeField | When site_status was last changed |
| `scheduled_publish_at` | DateTimeField | When content should be published (null if not scheduled) |
| `external_id` | CharField | WordPress post ID after publishing |
| `external_url` | URLField | WordPress post URL after publishing |
**Important:** These are separate concerns:
- `status` tracks editorial approval
- `site_status` tracks external publishing
- Content typically has `status='approved'` AND `site_status='not_published'` before scheduling
### PublishingSettings Fields
| Field | Type | Description |
|-------|------|-------------|
| `site` | ForeignKey | The site these settings apply to |
| `auto_publish_enabled` | BooleanField | Whether automatic scheduling is enabled |
| `publish_days` | JSONField | List of allowed days: `['mon', 'tue', 'wed', 'thu', 'fri']` |
| `publish_time_slots` | JSONField | List of times: `['09:00', '14:00', '18:00']` |
| `daily_publish_limit` | IntegerField | Max posts per day (null = unlimited) |
| `weekly_publish_limit` | IntegerField | Max posts per week (null = unlimited) |
| `monthly_publish_limit` | IntegerField | Max posts per month (null = unlimited) |
---
## Celery Beat Schedule
From `backend/igny8_core/celery.py`:
```python
app.conf.beat_schedule = {
# ...
'schedule-approved-content': {
'task': 'publishing.schedule_approved_content',
'schedule': crontab(minute=0), # Every hour at :00
},
'process-scheduled-publications': {
'task': 'publishing.process_scheduled_publications',
'schedule': crontab(minute='*/5'), # Every 5 minutes
},
# ...
}
```
---
## Manual Publishing
Content can also be published immediately via:
### API Endpoint
```
POST /api/v1/content/{content_id}/publish/
```
### Admin Action
In Django Admin, select content and use "Publish to WordPress" action.
---
## Monitoring & Debugging
### Log Files
- **Publish Logs:** `backend/logs/publish-sync-logs/`
- **API Logs:** `backend/logs/wordpress_api.log`
### Check Celery Status
```bash
docker compose -f docker-compose.app.yml -p igny8-app logs igny8_celery_worker
docker compose -f docker-compose.app.yml -p igny8-app logs igny8_celery_beat
```
### Check Scheduled Content
```python
# Django shell
from igny8_core.business.content.models import Content
from django.utils import timezone
# Past due content (should have been published)
Content.objects.filter(
site_status='scheduled',
scheduled_publish_at__lt=timezone.now()
).count()
# Upcoming scheduled content
Content.objects.filter(
site_status='scheduled',
scheduled_publish_at__gt=timezone.now()
).order_by('scheduled_publish_at')[:10]
```
### Manual Task Execution
```python
# Django shell
from igny8_core.tasks.publishing_scheduler import (
schedule_approved_content,
process_scheduled_publications
)
# Run scheduling task
schedule_approved_content()
# Process due publications
process_scheduled_publications()
```
---
## Error Handling
### Common Failure Reasons
| Error | Cause | Solution |
|-------|-------|----------|
| No active WordPress integration | Site doesn't have WordPress connected | Configure integration in Site settings |
| API key invalid/expired | WordPress API key issue | Regenerate API key in WordPress plugin |
| Connection timeout | WordPress site unreachable | Check site availability |
| Plugin not active | IGNY8 Bridge plugin disabled | Enable plugin in WordPress |
| Content already published | Duplicate publish attempt | Check `external_id` field |
### Retry Policy
- `publish_content_to_wordpress` has `max_retries=3`
- Automatic retry on transient failures
- Failed content marked with `site_status='failed'`
---
## Troubleshooting
### Content Not Being Scheduled
1. Check `PublishingSettings.auto_publish_enabled` is `True`
2. Verify content has `status='approved'` and `site_status='not_published'`
3. Check `scheduled_publish_at` is null (already scheduled content won't reschedule)
4. Verify publish limits haven't been reached
### Content Not Publishing
1. Check Celery Beat is running: `docker compose logs igny8_celery_beat`
2. Check Celery Worker is running: `docker compose logs igny8_celery_worker`
3. Look for errors in worker logs
4. Verify WordPress integration is active
5. Test WordPress API connectivity
### Resetting Failed Content
```python
# Reset failed content to try again
from igny8_core.business.content.models import Content
Content.objects.filter(site_status='failed').update(
site_status='not_published',
scheduled_publish_at=None
)
```
---
## Architecture Diagram
```
┌─────────────────────────────────────────────────────────────────┐
│ IGNY8 Backend │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Celery Beat │ │ Celery Worker │ │
│ │ │ │ │ │
│ │ Sends tasks at │───▶│ Executes tasks │ │
│ │ scheduled times │ │ │ │
│ └──────────────────┘ └────────┬─────────┘ │
│ │ │
│ ▼ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Publishing Tasks │ │
│ │ │ │
│ │ 1. schedule_approved_content (hourly) │ │
│ │ - Find approved content │ │
│ │ - Calculate publish slots │ │
│ │ - Set scheduled_publish_at │ │
│ │ │ │
│ │ 2. process_scheduled_publications (every 5 min) │ │
│ │ - Find due content │ │
│ │ - Queue publish_content_to_wordpress │ │
│ │ │ │
│ │ 3. publish_content_to_wordpress │ │
│ │ - Build API payload │ │
│ │ - Call WordPress REST API │ │
│ │ - Update content status │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ │
└───────────────────────────────────┼─────────────────────────────┘
▼ HTTPS
┌───────────────────────────────────────────────────────────────┐
│ WordPress Site │
├───────────────────────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ IGNY8 Bridge Plugin │ │
│ │ │ │
│ │ /wp-json/igny8-bridge/v1/publish │ │
│ │ - Receives content payload │ │
│ │ - Creates/updates WordPress post │ │
│ │ - Handles images, categories, tags │ │
│ │ - Returns post ID and URL │ │
│ └─────────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────┘
```
---
## Related Documentation
- [Publisher Module](../10-MODULES/PUBLISHER.md)
- [WordPress Integration](../60-PLUGINS/WORDPRESS-INTEGRATION.md)
- [Content Pipeline](CONTENT-PIPELINE.md)

View File

@@ -28,9 +28,10 @@
IGNY8 integrates with WordPress sites through a **custom WordPress plugin** (`igny8-wp-bridge`) that:
- Receives content from IGNY8 via a custom REST endpoint (`/wp-json/igny8/v1/publish`)
- Sends status updates back to IGNY8 via webhooks
- Authenticates using API keys stored in both systems
- **Authenticates using API key ONLY** (stored in Site.wp_api_key - single source of truth)
- Auto-updates via IGNY8 plugin distribution system (v1.7.0+)
- Supports advanced template rendering with image layouts
- **No WordPress admin credentials required** (username/password authentication deprecated)
### Communication Pattern
@@ -108,19 +109,22 @@ IGNY8 App ←→ WordPress Site
**Step 2: User clicks "Generate API Key"**
- Frontend calls: `POST /v1/integration/integrations/generate-api-key/`
- Body: `{ "site_id": 123 }`
- Backend creates/updates `SiteIntegration` record with new API key
- Backend stores API key in `Site.wp_api_key` field (SINGLE source of truth)
- Creates/updates `SiteIntegration` record with empty credentials_json
**Step 3: User configures WordPress plugin**
- Configures plugin with:
- IGNY8 API URL: `https://api.igny8.com`
- Site API Key: (copied from IGNY8)
- Site ID: (shown in IGNY8)
- **Note:** No WordPress admin credentials needed
**Step 4: Test Connection**
- User clicks "Test Connection" in either app
- IGNY8 calls: `GET {wordpress_url}/wp-json/wp/v2/users/me`
- Uses API key in `X-IGNY8-API-KEY` header
- Success: Connection verified, `is_active` set to true, plugin registers installation
- Plugin calls: `POST https://api.igny8.com/api/v1/integration/integrations/test-connection/`
- Headers: `Authorization: Bearer {api_key}`
- Body: `{ "site_id": 123, "api_key": "...", "site_url": "https://..." }`
- Backend validates API key against `Site.wp_api_key`
- Success: SiteIntegration created with empty credentials_json, plugin registers installation
- Failure: Error message displayed
### 2.4 Data Created During Setup
@@ -137,14 +141,100 @@ IGNY8 App ←→ WordPress Site
"site_url": "https://example.com"
},
"credentials_json": {
"api_key": "igny8_xxxxxxxxxxxxxxxxxxxx"
"plugin_version": "1.3.4",
"debug_enabled": false
},
"is_active": true,
"sync_enabled": true,
"sync_status": "pending"
"sync_status": "pending",
"_note": "API key stored in Site.wp_api_key, not in credentials_json"
}
```
**Site Model (API Key Storage):**
```json
{
"id": 123,
"name": "Example Site",
"url": "https://example.com",
"wp_api_key": "igny8_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"hosting_type": "wordpress"
}
```
---
## 2.5 Authentication Architecture (v1.3.4+)
### API Key as Single Source of Truth
**Storage:**
- API key stored in `Site.wp_api_key` field (Django backend)
- Plugin stores same API key in WordPress options table: `igny8_api_key`
- SiteIntegration.credentials_json does NOT contain API key
**Authentication Flow (IGNY8 → WordPress):**
```python
# Backend: publisher_service.py
destination_config = {
'site_url': integration.config_json.get('site_url'),
'api_key': integration.site.wp_api_key # From Site model
}
# WordPress Adapter: wordpress_adapter.py
headers = {
'Authorization': f'Bearer {api_key}',
'Content-Type': 'application/json'
}
requests.post(f"{site_url}/wp-json/igny8/v1/publish", headers=headers, json=payload)
```
**Authentication Flow (WordPress → IGNY8):**
```php
// Plugin: class-igny8-api.php
$api_key = get_option('igny8_api_key');
$headers = array(
'Authorization' => 'Bearer ' . $api_key,
'Content-Type' => 'application/json'
);
wp_remote_post('https://api.igny8.com/api/v1/...', array('headers' => $headers));
```
**Validation (WordPress Side):**
```php
// Plugin: class-igny8-rest-api.php
public function check_permission($request) {
// Check X-IGNY8-API-KEY header
$header_api_key = $request->get_header('x-igny8-api-key');
// Check Authorization Bearer header
$auth_header = $request->get_header('Authorization');
$stored_api_key = get_option('igny8_api_key');
if (hash_equals($stored_api_key, $header_api_key) ||
strpos($auth_header, 'Bearer ' . $stored_api_key) !== false) {
return true;
}
return new WP_Error('rest_forbidden', 'Invalid API key', array('status' => 401));
}
```
### Deprecated Authentication Methods
**No Longer Supported (removed in v1.3.4):**
- ❌ Username/password authentication
- ❌ App passwords via WordPress REST API
- ❌ OAuth/token exchange
- ❌ Webhook signature validation (webhooks deprecated)
- ❌ Storing API key in SiteIntegration.credentials_json
**Legacy Fields (do not use):**
- `Site.wp_username` - deprecated
- `Site.wp_app_password` - deprecated
- `Site.wp_url` - deprecated (use SiteIntegration.config_json.site_url)
---
## 3. Manual Publishing Flow
@@ -415,18 +505,119 @@ Refreshes understanding of WordPress site:
|-------|------|---------|
| id | AutoField | Primary key |
| account | FK(Account) | Owner account |
| site | FK(Site) | IGNY8 site |
| site | FK(Site) | IGNY8 site (contains wp_api_key) |
| platform | CharField | 'wordpress' |
| platform_type | CharField | 'cms' |
| config_json | JSONField | `{ "site_url": "https://..." }` |
| credentials_json | JSONField | `{ "api_key": "igny8_xxx" }` |
| credentials_json | JSONField | `{ "plugin_version": "1.3.4", "debug_enabled": false }` |
| is_active | Boolean | Connection enabled |
| sync_enabled | Boolean | Two-way sync enabled |
| last_sync_at | DateTime | Last successful sync |
| sync_status | CharField | pending/success/failed/syncing |
| sync_error | TextField | Last error message |
### 7.2 SyncEvent
**Note:** `credentials_json` no longer stores API key. API key is stored in `Site.wp_api_key` (single source of truth).
### 7.1a Site Model (API Key Storage)
| Field | Type | Purpose |
|-------|------|---------|-------|
| id | AutoField | Primary key |
| account | FK(Account) | Owner account |
| name | CharField | Site display name |
| url | URLField | WordPress site URL |
| wp_api_key | CharField | **API key for WordPress integration (SINGLE source of truth)** |
| wp_url | URLField | Legacy field (deprecated) |
| wp_username | CharField | Legacy field (deprecated) |
| wp_app_password | CharField | Legacy field (deprecated) |
| hosting_type | CharField | 'wordpress', 'shopify', 'igny8_sites', 'multi' |
### 7.2 Plugin Models
#### Plugin
Core plugin registry (platform-agnostic).
| Field | Type | Purpose |
|-------|------|---------|-------|
| id | AutoField | Primary key |
| name | CharField | Plugin display name (e.g., "IGNY8 WordPress Bridge") |
| slug | SlugField | URL-safe identifier (e.g., "igny8-wp-bridge") |
| platform | CharField | Target platform ('wordpress', 'shopify', etc.) |
| description | TextField | Plugin description |
| author | CharField | Plugin author |
| author_url | URLField | Author website |
| plugin_url | URLField | Plugin homepage |
| icon_url | URLField | Plugin icon (256x256) |
| banner_url | URLField | Plugin banner (772x250) |
| is_active | Boolean | Whether plugin is available for download |
| created_at | DateTime | Record creation |
| updated_at | DateTime | Last modified |
**Current WordPress Plugin:**
- Name: "IGNY8 WordPress Bridge"
- Slug: "igny8-wp-bridge"
- Platform: "wordpress"
- Description: "Connect your WordPress site to IGNY8 for AI-powered content publishing, SEO optimization, and seamless automation."
- Author: "IGNY8 Team"
#### PluginVersion
Version tracking with distribution files.
| Field | Type | Purpose |
|-------|------|---------|-------|
| id | AutoField | Primary key |
| plugin | FK(Plugin) | Parent plugin |
| version | CharField | Semantic version (e.g., "1.3.4") |
| status | CharField | 'development', 'beta', 'released', 'deprecated' |
| release_notes | TextField | Changelog/release notes |
| file_path | CharField | Path to ZIP file in /plugins/{platform}/dist/ |
| file_size | BigInteger | ZIP file size in bytes |
| checksum_md5 | CharField | MD5 hash for verification |
| checksum_sha256 | CharField | SHA256 hash for verification |
| requires_version | CharField | Minimum platform version (e.g., WP 5.6+) |
| tested_version | CharField | Tested up to version |
| is_latest | Boolean | Whether this is the latest stable version |
| download_count | Integer | Number of downloads |
| released_at | DateTime | Public release date |
| created_at | DateTime | Record creation |
**Current Latest Version (1.3.4):**
- Status: "released"
- File: `/plugins/wordpress/dist/igny8-wp-bridge-1.3.4.zip`
- Requires: WordPress 5.6+
- Tested: WordPress 6.4
- Features: API key authentication only, template improvements, image layout fixes
#### PluginInstallation
Tracks plugin installations per site.
| Field | Type | Purpose |
|-------|------|---------|-------|
| id | AutoField | Primary key |
| site | FK(Site) | Site where plugin is installed |
| plugin_version | FK(PluginVersion) | Installed version |
| installed_at | DateTime | Installation timestamp |
| last_seen | DateTime | Last health check |
| status | CharField | 'active', 'inactive', 'error' |
| metadata | JSONField | Installation-specific data (PHP version, WP version, etc.) |
#### PluginDownload
Download analytics.
| Field | Type | Purpose |
|-------|------|---------|-------|
| id | AutoField | Primary key |
| plugin_version | FK(PluginVersion) | Downloaded version |
| site | FK(Site, null=True) | Site that downloaded (if authenticated) |
| ip_address | GenericIPAddressField | Downloader IP |
| user_agent | TextField | Browser/client info |
| downloaded_at | DateTime | Download timestamp |
### 7.3 SyncEvent
| Field | Type | Purpose |
|-------|------|---------|
@@ -540,8 +731,7 @@ POST /api/plugins/igny8-wp-bridge/health-check/ - Health monitoring
### 8.3 Version History (Recent)
| Version | Date | Changes |
|---------|------|---------|
| 1.3.3 | Jan 10, 2026 | Template design: Square image grid fixes, landscape positioning, direct styling for images without captions |
|---------|------|---------|| 1.3.4 | Jan 12, 2026 | **API key authentication only** (removed username/password support), webhooks deprecated, Bearer token auth, simplified integration || 1.3.3 | Jan 10, 2026 | Template design: Square image grid fixes, landscape positioning, direct styling for images without captions |
| 1.3.2 | Jan 9, 2026 | Template rendering improvements, image layout enhancements |
| 1.3.1 | Jan 9, 2026 | Plugin versioning updates |
| 1.3.0 | Jan 8, 2026 | Distribution system release, auto-update mechanism |
@@ -616,42 +806,69 @@ POST /api/plugins/igny8-wp-bridge/health-check/ - Health monitoring
## 10. Flow Diagrams
### 9.1 Integration Setup
### 10.1 Integration Setup (API Key Authentication)
```
┌──────────┐ ┌──────────────┐ ┌───────────────┐
│ User │ │ IGNY8 App │ │ WordPress │
└────┬─────┘ └──────┬───────┘ └───────┬───────┘
┌──────────┐ ┌─────────────────┐ ┌───────────────┐
│ 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 │
│ │ │
<─────────────────┤ │
│ 3. Plugin ZIP │ │
5. Install & Activate Plugin──────────────────────>
│ │ │
4. Install Plugin──────────────────────┼──────────>
6. Enter API Key + Site ID in WP Settings─┼────────>
│ │ │
5. Generate API Key │
├─────────────────>│ │
│<─────────────────┤ │
│ 6. Display API Key │
7. Click "Test Connection" in Plugin──────┼────────>
│ │ │
7. Enter API Key in Plugin─────────────┼──────────>
│ 8. POST /api/v1/ │
│ │ integration/ │
│ │ integrations/ │
│ │ test-connection/ │
│ │<─────────────────────┤
│ │ Headers: │
│ │ Authorization: │
│ │ Bearer {api_key} │
│ │ │
8. Test Connection
├─────────────────>│ │
│ 9. GET /wp-json/...
├────────────────────>
│<────────────────────┤
<─────────────────┤ 10. Success
│ Validate against
│ Site.wp_api_key
│ Create/Update
│ SiteIntegration │
│ (credentials_json={})
│ │ │
│ │ 9. 200 OK │
│ ├─────────────────────>│
│ │ {success: true} │
│ │ │
│ 10. Success Message in Plugin─────────────┼────────>
│ │ │
│ │ 11. POST /register/ │
│ │<─────────────────────┤
│ │ Store PluginInstallation
│ │ │
│ │ 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
```

View File

@@ -1,6 +1,6 @@
/**
* WordPress Integration Form Component
* Inline form for WordPress integration with API key generation and plugin download
* Simplified - uses only Site.wp_api_key, no SiteIntegration model needed
*/
import React, { useState, useEffect } from 'react';
import { Card } from '../ui/card';
@@ -11,7 +11,6 @@ import Input from '../form/input/InputField';
import Checkbox from '../form/input/Checkbox';
import Switch from '../form/switch/Switch';
import { useToast } from '../ui/toast/ToastContainer';
import { integrationApi, SiteIntegration } from '../../services/integration.api';
import { fetchAPI, API_BASE_URL } from '../../services/api';
import {
CheckCircleIcon,
@@ -28,18 +27,18 @@ import {
interface WordPressIntegrationFormProps {
siteId: number;
integration: SiteIntegration | null;
siteName?: string;
siteUrl?: string;
onIntegrationUpdate?: (integration: SiteIntegration) => void;
wpApiKey?: string; // API key from Site.wp_api_key
onApiKeyUpdate?: (apiKey: string | null) => void;
}
export default function WordPressIntegrationForm({
siteId,
integration,
siteName,
siteUrl,
onIntegrationUpdate,
wpApiKey,
onApiKeyUpdate,
}: WordPressIntegrationFormProps) {
const toast = useToast();
const [loading, setLoading] = useState(false);
@@ -49,14 +48,19 @@ export default function WordPressIntegrationForm({
const [pluginInfo, setPluginInfo] = useState<any>(null);
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(() => {
if (integration?.api_key) {
setApiKey(integration.api_key);
if (wpApiKey) {
setApiKey(wpApiKey);
} else {
setApiKey('');
}
}, [integration]);
}, [wpApiKey]);
// Fetch plugin information
useEffect(() => {
@@ -75,11 +79,84 @@ export default function WordPressIntegrationForm({
fetchPluginInfo();
}, []);
// Test connection when API key exists
const testConnection = async () => {
if (!apiKey || !siteUrl) {
setConnectionStatus('unknown');
setConnectionMessage('API key or site URL missing');
return;
}
try {
setTestingConnection(true);
setConnectionStatus('testing');
setConnectionMessage('Testing connection...');
// Call backend to test connection to WordPress
// Backend reads API key from Site.wp_api_key (single source of truth)
const response = await fetchAPI('/v1/integration/integrations/test-connection/', {
method: 'POST',
body: JSON.stringify({
site_id: siteId,
}),
});
if (response.success) {
// Check the health checks from response
const healthChecks = response.health_checks || {};
// CRITICAL: api_key_verified confirms WordPress accepts our API key
if (healthChecks.api_key_verified) {
setConnectionStatus('connected');
setConnectionMessage('WordPress is connected and API key verified');
toast.success('WordPress connection verified!');
} else if (healthChecks.plugin_has_api_key && !healthChecks.api_key_verified) {
// WordPress has A key, but it's NOT the same as IGNY8's key
setConnectionStatus('api_key_pending');
setConnectionMessage('API key mismatch - copy the key from IGNY8 to WordPress plugin');
toast.warning('WordPress has different API key. Please update WordPress with the key shown above.');
} else if (healthChecks.plugin_installed && !healthChecks.plugin_has_api_key) {
setConnectionStatus('api_key_pending');
setConnectionMessage('Plugin installed - please add API key in WordPress');
toast.warning('Plugin found but API key not configured in WordPress');
} else if (!healthChecks.plugin_installed) {
setConnectionStatus('plugin_missing');
setConnectionMessage('IGNY8 plugin not installed on WordPress site');
toast.warning('WordPress site reachable but plugin not found');
} else {
setConnectionStatus('error');
setConnectionMessage(response.message || 'Connection verification incomplete');
toast.error(response.message || 'Connection test incomplete');
}
} else {
setConnectionStatus('error');
setConnectionMessage(response.message || 'Connection test failed');
toast.error(response.message || 'Connection test failed');
}
} catch (error: any) {
setConnectionStatus('error');
setConnectionMessage(error.message || 'Connection test failed');
toast.error(`Connection test failed: ${error.message}`);
} finally {
setTestingConnection(false);
}
};
// Auto-test connection when API key changes
useEffect(() => {
if (apiKey && siteUrl) {
testConnection();
} else {
setConnectionStatus('unknown');
setConnectionMessage('');
}
}, [apiKey, siteUrl]);
const handleGenerateApiKey = async () => {
try {
setGeneratingKey(true);
// Call the new generate-api-key endpoint
// Call the simplified generate-api-key endpoint
const response = await fetchAPI('/v1/integration/integrations/generate-api-key/', {
method: 'POST',
body: JSON.stringify({ site_id: siteId }),
@@ -89,9 +166,9 @@ export default function WordPressIntegrationForm({
setApiKey(newKey);
setApiKeyVisible(true);
// Trigger integration update
if (onIntegrationUpdate && response.integration) {
onIntegrationUpdate(response.integration);
// Notify parent component
if (onApiKeyUpdate) {
onApiKeyUpdate(newKey);
}
toast.success('API key generated successfully');
@@ -119,9 +196,9 @@ export default function WordPressIntegrationForm({
setApiKey(newKey);
setApiKeyVisible(true);
// Trigger integration update
if (onIntegrationUpdate && response.integration) {
onIntegrationUpdate(response.integration);
// Notify parent component
if (onApiKeyUpdate) {
onApiKeyUpdate(newKey);
}
toast.success('API key regenerated successfully');
@@ -139,20 +216,20 @@ export default function WordPressIntegrationForm({
try {
setGeneratingKey(true);
if (!integration) {
toast.error('No integration found');
return;
}
// Delete the integration to revoke the API key
await integrationApi.deleteIntegration(integration.id);
// Revoke API key via dedicated endpoint (single source of truth: Site.wp_api_key)
await fetchAPI('/v1/integration/integrations/revoke-api-key/', {
method: 'POST',
body: JSON.stringify({ site_id: siteId }),
});
setApiKey('');
setApiKeyVisible(false);
setConnectionStatus('unknown');
setConnectionMessage('');
// Trigger integration update
if (onIntegrationUpdate) {
onIntegrationUpdate(null as any);
// Notify parent component
if (onApiKeyUpdate) {
onApiKeyUpdate(null);
}
toast.success('API key revoked successfully');
@@ -183,47 +260,9 @@ export default function WordPressIntegrationForm({
return key.substring(0, 8) + '**********' + key.substring(key.length - 4);
};
// Toggle integration sync enabled status (not creation - that happens automatically)
const [integrationEnabled, setIntegrationEnabled] = useState(integration?.sync_enabled ?? false);
const handleToggleIntegration = async (enabled: boolean) => {
try {
setIntegrationEnabled(enabled);
if (integration) {
// Update existing integration - only toggle sync_enabled, not creation
await integrationApi.updateIntegration(integration.id, {
sync_enabled: enabled,
} as any);
toast.success(enabled ? 'Sync enabled' : 'Sync disabled');
// Reload integration
const updated = await integrationApi.getWordPressIntegration(siteId);
if (onIntegrationUpdate && updated) {
onIntegrationUpdate(updated);
}
} else {
// Integration doesn't exist - it should be created automatically by plugin
// when user connects from WordPress side
toast.info('Integration will be created automatically when you connect from WordPress plugin. Please connect from the plugin first.');
setIntegrationEnabled(false);
}
} catch (error: any) {
toast.error(`Failed to update integration: ${error.message}`);
// Revert on error
setIntegrationEnabled(!enabled);
}
};
useEffect(() => {
if (integration) {
setIntegrationEnabled(integration.sync_enabled ?? false);
}
}, [integration]);
return (
<div className="space-y-6">
{/* Header with Toggle */}
{/* Header */}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="p-3 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
@@ -239,13 +278,60 @@ export default function WordPressIntegrationForm({
</div>
</div>
{/* Toggle Switch */}
{/* Connection Status */}
{apiKey && (
<Switch
label={integrationEnabled ? 'Sync Enabled' : 'Sync Disabled'}
checked={integrationEnabled}
onChange={(checked) => handleToggleIntegration(checked)}
/>
<div className="flex items-center gap-2">
{/* Status Badge */}
<div className={`flex items-center gap-2 px-3 py-1.5 rounded-lg border ${
connectionStatus === 'connected'
? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800'
: connectionStatus === 'testing'
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800'
: connectionStatus === 'api_key_pending'
? 'bg-amber-50 dark:bg-amber-900/20 border-amber-200 dark:border-amber-800'
: connectionStatus === 'plugin_missing'
? 'bg-amber-50 dark:bg-amber-900/20 border-amber-200 dark:border-amber-800'
: connectionStatus === 'error'
? 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800'
: 'bg-gray-50 dark:bg-gray-800/50 border-gray-200 dark:border-gray-700'
}`}>
{connectionStatus === 'connected' && (
<><CheckCircleIcon className="w-4 h-4 text-green-600 dark:text-green-400" />
<span className="text-sm font-medium text-green-700 dark:text-green-300">Connected</span></>
)}
{connectionStatus === 'testing' && (
<><RefreshCwIcon className="w-4 h-4 text-blue-600 dark:text-blue-400 animate-spin" />
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">Testing...</span></>
)}
{connectionStatus === 'api_key_pending' && (
<><AlertIcon className="w-4 h-4 text-amber-600 dark:text-amber-400" />
<span className="text-sm font-medium text-amber-700 dark:text-amber-300">Pending Setup</span></>
)}
{connectionStatus === 'plugin_missing' && (
<><AlertIcon className="w-4 h-4 text-amber-600 dark:text-amber-400" />
<span className="text-sm font-medium text-amber-700 dark:text-amber-300">Plugin Missing</span></>
)}
{connectionStatus === 'error' && (
<><AlertIcon className="w-4 h-4 text-red-600 dark:text-red-400" />
<span className="text-sm font-medium text-red-700 dark:text-red-300">Error</span></>
)}
{connectionStatus === 'unknown' && (
<><InfoIcon className="w-4 h-4 text-gray-500 dark:text-gray-400" />
<span className="text-sm font-medium text-gray-600 dark:text-gray-400">Not Tested</span></>
)}
</div>
{/* Test Connection Button */}
<Button
onClick={testConnection}
variant="outline"
size="sm"
disabled={testingConnection || !apiKey}
startIcon={<RefreshCwIcon className={`w-4 h-4 ${testingConnection ? 'animate-spin' : ''}`} />}
>
Test
</Button>
</div>
)}
</div>
@@ -264,18 +350,9 @@ export default function WordPressIntegrationForm({
onClick={handleGenerateApiKey}
variant="solid"
disabled={generatingKey}
startIcon={generatingKey ? <RefreshCwIcon className="w-4 h-4 animate-spin" /> : <PlusIcon className="w-4 h-4" />}
>
{generatingKey ? (
<>
<RefreshCwIcon className="w-4 h-4 mr-2 animate-spin" />
Generating...
</>
) : (
<>
<PlusIcon className="w-4 h-4 mr-2" />
Add API Key
</>
)}
{generatingKey ? 'Generating...' : 'Add API Key'}
</Button>
</div>
)}
@@ -306,6 +383,7 @@ export default function WordPressIntegrationForm({
readOnly
type={apiKeyVisible ? 'text' : 'password'}
value={apiKeyVisible ? apiKey : maskApiKey(apiKey)}
onChange={() => {}} // No-op to satisfy React
/>
<IconButton
onClick={handleCopyApiKey}

View File

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

View File

@@ -188,9 +188,21 @@ export const createImagesPageConfig = (
type: 'text',
placeholder: 'Search by content title...',
},
{
key: 'content_status',
label: 'Content Status',
type: 'select',
options: [
{ value: '', label: 'All' },
{ value: 'draft', label: 'Draft' },
{ value: 'review', label: 'Review' },
{ value: 'approved', label: 'Approved' },
{ value: 'published', label: 'Published' },
],
},
{
key: 'status',
label: 'Status',
label: 'Image Status',
type: 'select',
options: [
{ value: '', label: 'All Status' },

View File

@@ -7,7 +7,7 @@ import { Content } from '../../services/api';
import Badge from '../../components/ui/badge/Badge';
import { formatRelativeDate } from '../../utils/date';
import { CheckCircleIcon } from '../../icons';
import { STRUCTURE_LABELS, TYPE_LABELS } from '../structureMapping';
import { STRUCTURE_LABELS, TYPE_LABELS, CONTENT_TYPE_OPTIONS, ALL_CONTENT_STRUCTURES } from '../structureMapping';
export interface ColumnConfig {
key: string;
@@ -256,6 +256,49 @@ export function createReviewPageConfig(params: {
type: 'text',
placeholder: 'Search content...',
},
{
key: 'status',
label: 'Status',
type: 'select',
options: [
{ value: '', label: 'All' },
{ value: 'draft', label: 'Draft' },
{ value: 'review', label: 'Review' },
{ value: 'approved', label: 'Approved' },
{ value: 'published', label: 'Published' },
],
},
{
key: 'site_status',
label: 'Site Status',
type: 'select',
options: [
{ value: '', label: 'All' },
{ value: 'not_published', label: 'Not Published' },
{ value: 'scheduled', label: 'Scheduled' },
{ value: 'publishing', label: 'Publishing' },
{ value: 'published', label: 'Published' },
{ value: 'failed', label: 'Failed' },
],
},
{
key: 'content_type',
label: 'Type',
type: 'select',
options: [
{ value: '', label: 'All Types' },
...CONTENT_TYPE_OPTIONS,
],
},
{
key: 'content_structure',
label: 'Structure',
type: 'select',
options: [
{ value: '', label: 'All Structures' },
...ALL_CONTENT_STRUCTURES,
],
},
],
headerMetrics: [
{

View File

@@ -29,7 +29,6 @@ import {
} from '../../services/api';
import { useSiteStore } from '../../store/siteStore';
import WordPressIntegrationForm from '../../components/sites/WordPressIntegrationForm';
import { integrationApi, SiteIntegration } from '../../services/integration.api';
import { GridIcon, PlugInIcon, PaperPlaneIcon, DocsIcon, BoltIcon, FileIcon, ChevronDownIcon, CloseIcon, PlusIcon, RefreshCwIcon, FileTextIcon, ImageIcon, SaveIcon, Loader2Icon, ArrowRightIcon, SettingsIcon, GlobeIcon, LayersIcon, CheckCircleIcon, CalendarIcon, InfoIcon } from '../../icons';
import Badge from '../../components/ui/badge/Badge';
import { Dropdown } from '../../components/ui/dropdown/Dropdown';
@@ -45,8 +44,6 @@ export default function SiteSettings() {
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [site, setSite] = useState<any>(null);
const [wordPressIntegration, setWordPressIntegration] = useState<SiteIntegration | null>(null);
const [integrationLoading, setIntegrationLoading] = useState(false);
// Site selector state
const [sites, setSites] = useState<Site[]>([]);
@@ -134,12 +131,10 @@ export default function SiteSettings() {
useEffect(() => {
if (siteId) {
// Clear state when site changes
setWordPressIntegration(null);
setSite(null);
// Load new site data
loadSite();
loadIntegrations();
loadIndustries();
}
}, [siteId]);
@@ -248,17 +243,10 @@ export default function SiteSettings() {
}
};
const loadIntegrations = async () => {
if (!siteId) return;
try {
setIntegrationLoading(true);
const integration = await integrationApi.getWordPressIntegration(Number(siteId));
setWordPressIntegration(integration);
} catch (error: any) {
// Integration might not exist, that's okay
setWordPressIntegration(null);
} finally {
setIntegrationLoading(false);
const handleApiKeyUpdate = (newApiKey: string | null) => {
// Update site state with new API key
if (site) {
setSite({ ...site, wp_api_key: newApiKey });
}
};
@@ -495,11 +483,6 @@ export default function SiteSettings() {
}
};
const handleIntegrationUpdate = async (integration: SiteIntegration) => {
setWordPressIntegration(integration);
await loadIntegrations();
};
const formatRelativeTime = (iso: string | null) => {
if (!iso) return '-';
const then = new Date(iso).getTime();
@@ -516,83 +499,56 @@ export default function SiteSettings() {
return `${months}mo ago`;
};
// Integration status with authentication check
// Integration status - tracks actual connection state
const [integrationStatus, setIntegrationStatus] = useState<'connected' | 'configured' | 'not_configured'>('not_configured');
const [testingAuth, setTestingAuth] = useState(false);
// Check basic configuration - integration must exist in DB and have sync_enabled
// Check integration status based on API key presence (will be updated by WordPressIntegrationForm)
useEffect(() => {
const checkStatus = async () => {
// Integration must exist in database and have sync_enabled = true
if (wordPressIntegration && wordPressIntegration.id && wordPressIntegration.sync_enabled) {
if (site?.wp_api_key) {
// API key exists - mark as configured (actual connection tested in WordPressIntegrationForm)
setIntegrationStatus('configured');
// Test authentication
testAuthentication();
} else {
setIntegrationStatus('not_configured');
}
};
checkStatus();
}, [wordPressIntegration, site]);
}, [site?.wp_api_key]);
// Auto-refresh integration list periodically to detect plugin-created integrations
useEffect(() => {
const interval = setInterval(() => {
if (!wordPressIntegration) {
loadIntegrations();
}
}, 5000); // Check every 5 seconds if integration doesn't exist
return () => clearInterval(interval);
}, [wordPressIntegration]);
// Test authentication with WordPress API
const testAuthentication = async () => {
if (testingAuth || !wordPressIntegration?.id) return;
try {
setTestingAuth(true);
const resp = await fetchAPI(`/v1/integration/integrations/${wordPressIntegration.id}/test_connection/`, {
method: 'POST',
body: {}
});
if (resp && resp.success) {
setIntegrationStatus('connected');
} else {
// Keep as 'configured' if auth fails
setIntegrationStatus('configured');
}
} catch (err) {
// Keep as 'configured' if auth test fails
setIntegrationStatus('configured');
} finally {
setTestingAuth(false);
}
};
// Sync Now handler extracted
// Sync Now handler - tests actual WordPress connection
const [syncLoading, setSyncLoading] = useState(false);
const [lastSyncTime, setLastSyncTime] = useState<string | null>(null);
const handleManualSync = async () => {
if (!site?.wp_api_key) {
toast.error('WordPress API key not configured. Please generate an API key first.');
return;
}
setSyncLoading(true);
try {
if (wordPressIntegration && wordPressIntegration.id) {
const res = await integrationApi.syncIntegration(wordPressIntegration.id, 'metadata');
// Test connection to WordPress using backend test endpoint
// Backend reads API key from Site.wp_api_key (single source of truth)
const res = await fetchAPI('/v1/integration/integrations/test-connection/', {
method: 'POST',
body: JSON.stringify({
site_id: siteId,
}),
});
if (res && res.success) {
toast.success('WordPress structure synced successfully');
if (res.last_sync_at) {
setLastSyncTime(res.last_sync_at);
}
setTimeout(() => loadContentTypes(), 1500);
// Check health checks
const healthChecks = res.health_checks || {};
if (healthChecks.plugin_has_api_key) {
setIntegrationStatus('connected');
toast.success('WordPress connection verified - fully connected!');
} else if (healthChecks.plugin_installed) {
setIntegrationStatus('configured');
toast.warning('Plugin found but API key not configured in WordPress');
} else {
toast.error(res?.message || 'Sync failed to start');
toast.warning('WordPress reachable but IGNY8 plugin not installed');
}
setLastSyncTime(new Date().toISOString());
} else {
toast.error('No integration configured. Please configure WordPress integration first.');
toast.error(res?.message || 'Connection test failed');
}
} catch (err: any) {
toast.error(`Sync failed: ${err?.message || String(err)}`);
toast.error(`Connection test failed: ${err?.message || String(err)}`);
} finally {
setSyncLoading(false);
}
@@ -739,7 +695,7 @@ export default function SiteSettings() {
/>
<span className="text-sm font-medium text-gray-700 dark:text-gray-200">
{integrationStatus === 'connected' && 'Connected'}
{integrationStatus === 'configured' && (testingAuth ? 'Testing...' : 'Configured')}
{integrationStatus === 'configured' && 'Configured'}
{integrationStatus === 'not_configured' && 'Not Configured'}
</span>
</div>
@@ -1874,10 +1830,10 @@ export default function SiteSettings() {
{activeTab === 'integrations' && siteId && (
<WordPressIntegrationForm
siteId={Number(siteId)}
integration={wordPressIntegration}
siteName={site?.name}
siteUrl={site?.domain || site?.wp_url}
onIntegrationUpdate={handleIntegrationUpdate}
wpApiKey={site?.wp_api_key}
onApiKeyUpdate={handleApiKeyUpdate}
/>
)}
</div>

View File

@@ -13,7 +13,6 @@ import {
ContentListResponse,
ContentFilters,
fetchAPI,
fetchWordPressStatus,
deleteContent,
bulkDeleteContent,
} from '../../services/api';
@@ -46,9 +45,12 @@ export default function Approved() {
const [totalPublished, setTotalPublished] = useState(0);
const [totalImagesCount, setTotalImagesCount] = useState(0);
// Filter state - default to approved status
// Filter state
const [searchTerm, setSearchTerm] = useState('');
const [publishStatusFilter, setPublishStatusFilter] = useState('');
const [statusFilter, setStatusFilter] = useState(''); // Status filter (draft/review/approved/published)
const [siteStatusFilter, setSiteStatusFilter] = useState(''); // Site status filter (not_published/scheduled/published/failed)
const [contentTypeFilter, setContentTypeFilter] = useState(''); // Content type filter (post/page/product/taxonomy)
const [contentStructureFilter, setContentStructureFilter] = useState(''); // Content structure filter
const [selectedIds, setSelectedIds] = useState<string[]>([]);
// Pagination state
@@ -99,7 +101,10 @@ export default function Approved() {
const filters: ContentFilters = {
...(searchTerm && { search: searchTerm }),
status__in: 'approved,published', // Both approved and published content
// Default to approved+published if no status filter selected
...(statusFilter ? { status: statusFilter } : { status__in: 'approved,published' }),
...(contentTypeFilter && { content_type: contentTypeFilter }),
...(contentStructureFilter && { content_structure: contentStructureFilter }),
page: currentPage,
page_size: pageSize,
ordering,
@@ -107,34 +112,13 @@ export default function Approved() {
const data: ContentListResponse = await fetchContent(filters);
// Client-side filter for WordPress publish status if needed
// Client-side filter for site_status if needed (backend may not support this filter yet)
let filteredResults = data.results || [];
if (publishStatusFilter === 'published') {
filteredResults = filteredResults.filter(c => c.external_id);
} else if (publishStatusFilter === 'not_published') {
filteredResults = filteredResults.filter(c => !c.external_id);
if (siteStatusFilter) {
filteredResults = filteredResults.filter(c => c.site_status === siteStatusFilter);
}
// Fetch WordPress status for published content
const resultsWithWPStatus = await Promise.all(
filteredResults.map(async (content) => {
if (content.external_id) {
try {
const wpStatus = await fetchWordPressStatus(content.id);
return {
...content,
wordpress_status: wpStatus.wordpress_status,
};
} catch (error) {
console.warn(`Failed to fetch WP status for content ${content.id}:`, error);
return content;
}
}
return content;
})
);
setContent(resultsWithWPStatus);
setContent(filteredResults);
setTotalCount(data.count || 0);
setTotalPages(Math.ceil((data.count || 0) / pageSize));
@@ -148,7 +132,7 @@ export default function Approved() {
setShowContent(true);
setLoading(false);
}
}, [currentPage, publishStatusFilter, sortBy, sortDirection, searchTerm, pageSize, toast]);
}, [currentPage, statusFilter, siteStatusFilter, contentTypeFilter, contentStructureFilter, sortBy, sortDirection, searchTerm, pageSize, toast]);
useEffect(() => {
loadContent();
@@ -326,15 +310,17 @@ export default function Approved() {
return createApprovedPageConfig({
searchTerm,
setSearchTerm,
publishStatusFilter,
setPublishStatusFilter,
statusFilter,
setStatusFilter,
siteStatusFilter,
setSiteStatusFilter,
setCurrentPage,
activeSector,
onRowClick: (row: Content) => {
navigate(`/writer/content/${row.id}`);
},
});
}, [searchTerm, publishStatusFilter, activeSector, navigate]);
}, [searchTerm, statusFilter, siteStatusFilter, contentTypeFilter, contentStructureFilter, activeSector, navigate]);
// Calculate header metrics - use totals from API calls (not page data)
// This ensures metrics show correct totals across all pages, not just current page
@@ -392,7 +378,10 @@ export default function Approved() {
filters={pageConfig.filters}
filterValues={{
search: searchTerm,
publishStatus: publishStatusFilter,
status: statusFilter,
site_status: siteStatusFilter,
content_type: contentTypeFilter,
content_structure: contentStructureFilter,
}}
primaryAction={{
label: 'Publish to Site',
@@ -403,8 +392,17 @@ export default function Approved() {
onFilterChange={(key: string, value: any) => {
if (key === 'search') {
setSearchTerm(value);
} else if (key === 'publishStatus') {
setPublishStatusFilter(value);
} else if (key === 'status') {
setStatusFilter(value);
setCurrentPage(1);
} else if (key === 'site_status') {
setSiteStatusFilter(value);
setCurrentPage(1);
} else if (key === 'content_type') {
setContentTypeFilter(value);
setCurrentPage(1);
} else if (key === 'content_structure') {
setContentStructureFilter(value);
setCurrentPage(1);
}
}}

View File

@@ -25,6 +25,33 @@ export function formatRelativeDate(dateString: string | Date): string {
const diffTime = today.getTime() - dateOnly.getTime();
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
// Handle future dates (negative diffDays)
if (diffDays < 0) {
const futureDays = Math.abs(diffDays);
if (futureDays === 1) {
return 'Tomorrow';
} else if (futureDays < 30) {
return `in ${futureDays} days`;
} else if (futureDays < 365) {
const months = Math.floor(futureDays / 30);
const remainingDays = futureDays % 30;
if (remainingDays === 0) {
return `in ${months} month${months > 1 ? 's' : ''}`;
} else {
return `in ${months} month${months > 1 ? 's' : ''} ${remainingDays} day${remainingDays > 1 ? 's' : ''}`;
}
} else {
const years = Math.floor(futureDays / 365);
const remainingMonths = Math.floor((futureDays % 365) / 30);
if (remainingMonths === 0) {
return `in ${years} year${years > 1 ? 's' : ''}`;
} else {
return `in ${years} year${years > 1 ? 's' : ''} ${remainingMonths} month${remainingMonths > 1 ? 's' : ''}`;
}
}
}
// Handle past dates (positive diffDays)
if (diffDays === 0) {
return 'Today';
} else if (diffDays === 1) {

View File

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

View File

@@ -85,6 +85,14 @@ class Igny8RestAPI {
'permission_callback' => '__return_true', // Public endpoint for health checks
));
// API key verification endpoint - requires valid API key in header
// Used by IGNY8 to verify the API keys match
register_rest_route('igny8/v1', '/verify-key', array(
'methods' => 'GET',
'callback' => array($this, 'verify_api_key'),
'permission_callback' => array($this, 'check_permission'),
));
// Manual publish endpoint - for triggering WordPress publish from IGNY8
// Route: /wp-json/igny8/v1/publish
register_rest_route('igny8/v1', '/publish', array(
@@ -406,6 +414,28 @@ class Igny8RestAPI {
return $this->build_unified_response(true, $data, 'Plugin status retrieved', null, null, 200);
}
/**
* GET /verify-key - Verify API key is valid and matches
* This endpoint requires authentication - if we get here, the API key is valid
*
* @param WP_REST_Request $request
* @return WP_REST_Response
*/
public function verify_api_key($request) {
// If we reach here, check_permission passed, meaning API key is valid
$api_key = function_exists('igny8_get_secure_option') ? igny8_get_secure_option('igny8_api_key') : get_option('igny8_api_key');
$site_id = get_option('igny8_site_id', '');
$data = array(
'verified' => true,
'site_id' => $site_id,
'plugin_version' => defined('IGNY8_BRIDGE_VERSION') ? IGNY8_BRIDGE_VERSION : '1.0.0',
'api_key_prefix' => !empty($api_key) ? substr($api_key, 0, 15) . '...' : null,
);
return $this->build_unified_response(true, $data, 'API key verified successfully', null, null, 200);
}
/**
* GET /site-metadata/ - returns post types, taxonomies and counts in unified format
*