# Scheduled Content Publishing Workflow **Last Updated:** January 16, 2026 **Module:** Publishing / Automation **Status:** ✅ Site filtering fixed and verified --- ## Overview IGNY8 provides automated content publishing to WordPress, Shopify, and custom sites. Content goes through a scheduling process before being published at the designated time. **Current System (v2.0):** - Site credentials stored directly on `Site` model (`api_key`, `domain`, `platform_type`) - Multi-platform support: WordPress, Shopify, Custom APIs - No `SiteIntegration` model required - Publishing via `PublisherService` (not legacy Celery tasks) - API endpoint: `POST /api/v1/publisher/publish` - **Site Filtering:** All content queries filtered by `site_id` (fixed Jan 2026) --- ## 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 (platform-agnostic) - `not_published` - Not yet published to any site - `scheduled` - Has a scheduled_publish_at time - `publishing` - Currently being published - `published` - Successfully published to site (WordPress, Shopify, or Custom) - `failed` - Publishing failed **Note:** `site_status` works across all platforms (WordPress, Shopify, Custom) and is filtered by `site_id` to show only content for the selected site. ### 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 │ └─────────┘ └────────┘ ``` --- ## Frontend Integration ### Site Selector & Content Filtering **Content Calendar** (`frontend/src/pages/Publisher/ContentCalendar.tsx`): - Automatically filters all content by selected site - Reloads data when site selector changes - Shows scheduled, publishing, published, and failed content for active site only **Critical Implementation Details:** - All API queries include `site_id: activeSite.id` parameter - Backend `ContentFilter` includes `site_id` in filterable fields - useEffect hook reacts to `activeSite?.id` changes to trigger reload - Content cleared when no site selected **Site Selector Fix (Jan 2026):** - Fixed circular dependency in useEffect (lines 285-294) - Only depends on `activeSite?.id`, not on callback functions - Added console logging for debugging site changes - Pattern follows Dashboard and Approved pages **Backend Filter Configuration:** ```python # backend/igny8_core/modules/writer/views.py class ContentFilter(django_filters.FilterSet): class Meta: model = Content fields = [ 'cluster_id', 'site_id', # Required for site filtering 'status', 'site_status', # Required for status filtering 'content_type', # ... ] ``` --- ## 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: - Validates WordPress configuration exists on Site (`wp_api_key`, `domain`) - Updates `site_status='publishing'` - Calls `PublisherService.publish_content()` directly (synchronous) - Updates `site_status='published'` on success or `'failed'` on error 3. Logs results and any errors **Current Implementation (v2.0):** - Uses `PublisherService` (current system) - Gets config from `Site.wp_api_key` and `Site.domain` - No `SiteIntegration` required - Synchronous publishing (not queued to Celery) --- ### 3. `PublisherService.publish_content` **Type:** Service method (called by scheduler) **File:** `backend/igny8_core/business/publishing/services/publisher_service.py` #### What It Does: 1. **Load Content** - Gets content by ID 2. **Get WordPress Config** - Reads from `Site.wp_api_key` and `Site.domain` 3. **Call Adapter** - Uses `WordPressAdapter` to handle API communication 4. **WordPress API Call** - POSTs to `{domain}/wp-json/igny8/v1/publish` 5. **Update Content** - Sets `external_id`, `external_url`, `site_status='published'` 6. **Create Record** - Logs in `PublishingRecord` model #### WordPress Connection: - Uses the IGNY8 WordPress Bridge plugin installed on the site - API endpoint: `{site.domain}/wp-json/igny8/v1/publish` - Authentication: API key from `Site.wp_api_key` - **No SiteIntegration model needed** --- ## 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 be published immediately or rescheduled via API: ### Publish Now ``` POST /api/v1/publisher/publish { "content_id": 123, "destinations": ["wordpress"] } ``` ### Schedule for Later ``` POST /api/v1/writer/content/{id}/schedule/ { "scheduled_at": "2026-01-20T14:00:00Z" } ``` ### Reschedule Failed Content ``` POST /api/v1/writer/content/{id}/reschedule/ { "scheduled_at": "2026-01-20T15:00:00Z" } ``` **Note:** Reschedule works from any `site_status` (failed, published, scheduled, etc.) ### Unschedule ``` POST /api/v1/writer/content/{id}/unschedule/ ``` --- ## Monitoring & Debugging ### Frontend Debugging **Browser Console Logs:** When changing sites in Content Calendar, you should see: ``` [ContentCalendar] Site changed to: 45 My Site Name [ContentCalendar] Triggering loadQueue... ``` **Check Site Filtering:** 1. Open browser DevTools → Network tab 2. Change site in site selector 3. Look for API calls to `/api/v1/writer/content/` 4. Verify `site_id` parameter is included in query string 5. Verify count matches database for that site **Common Issues:** - No console logs when changing sites → useEffect not triggering (refresh page) - API calls missing `site_id` parameter → backend filter not working - Wrong count displayed → database query issue or cache problem ### Backend 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] # Check scheduled content for specific site site_id = 45 Content.objects.filter( site_id=site_id, site_status='scheduled' ).count() # Compare with frontend display # Should match count shown in Content Calendar for that site ``` ### 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 WordPress API key | Site.wp_api_key is empty | Configure API key in Site settings | | No domain configured | Site.domain is empty | Set domain 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 | Use reschedule to republish | ### Retry Policy - Failed content marked with `site_status='failed'` - Use the `reschedule` action to retry publishing - Can reschedule from any status (failed, published, etc.) ### Rescheduling Failed Content ```python # Via API POST /api/v1/writer/content/{id}/reschedule/ { "scheduled_at": "2026-01-20T15:00:00Z" } # Via Django shell from igny8_core.business.content.models import Content from django.utils import timezone from datetime import timedelta failed_content = Content.objects.filter(site_status='failed') for content in failed_content: # Reschedule for 1 hour from now content.site_status = 'scheduled' content.scheduled_publish_at = timezone.now() + timedelta(hours=1) content.site_status_updated_at = timezone.now() content.save(update_fields=['site_status', 'scheduled_publish_at', 'site_status_updated_at']) ``` --- ## Troubleshooting ### Site Selector Not Updating Content Calendar **Symptoms:** - Changing sites doesn't reload calendar content - Count shows wrong number of scheduled items - Content from wrong site displayed **Solution:** 1. **Hard refresh browser:** Ctrl+F5 (Windows/Linux) or Cmd+Shift+R (Mac) 2. **Clear browser cache** 3. **Check console logs:** Should see `[ContentCalendar] Site changed to: ...` 4. **Verify API calls:** Check Network tab for `site_id` parameter **What Was Fixed (Jan 2026):** - Frontend: Fixed useEffect circular dependency in ContentCalendar.tsx - Backend: Added `site_id` and `site_status` to ContentFilter fields - All API queries now properly filter by `site_id: activeSite.id` - Content clears when no site selected **If Problem Persists:** ```bash # Check backend filter configuration grep -n "site_id" backend/igny8_core/modules/writer/views.py # Should show site_id in ContentFilter.Meta.fields ``` ### 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 5. **Verify correct site selected** in site selector ### 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 Site has `wp_api_key` configured 5. Verify Site has `domain` configured 6. Test WordPress API connectivity ### Resetting Failed Content Use the reschedule API endpoint: ```bash # Reschedule single content curl -X POST https://api.igny8.com/api/v1/writer/content/344/reschedule/ \ -H "Authorization: Api-Key YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{"scheduled_at": "2026-01-20T15:00:00Z"}' ``` Or via Django shell: ```python # Reset all failed content to reschedule in 1 hour from igny8_core.business.content.models import Content from django.utils import timezone from datetime import timedelta failed_content = Content.objects.filter(site_status='failed') for content in failed_content: content.site_status = 'scheduled' content.scheduled_publish_at = timezone.now() + timedelta(hours=1) content.site_status_updated_at = timezone.now() content.save(update_fields=['site_status', 'scheduled_publish_at', 'site_status_updated_at']) print(f"Rescheduled content {content.id}") ``` --- ## Architecture Diagram ``` ┌─────────────────────────────────────────────────────────────────┐ │ IGNY8 Backend │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ ┌───────Validate Site.wp_api_key & domain │ │ │ │ - Call PublisherService.publish_content() │ │ │ │ │ │ │ │ 3. PublisherService │ │ │ │ - Get config from Site model │ │ │ │ - Call WordPressAdapter │ │ │ │ - Update content status │ │ │ └────────────────────────────────────────────────────────┘ │ │ │ │ └───────────────────────────────────┼─────────────────────────────┘ │ ▼ HTTPS ┌───────────────────────────────────────────────────────────────┐ │ WordPress Site │ ├───────────────────────────────────────────────────────────────┤ │ ┌─────────────────────────────────────────────────────────┐ │ │ │ IGNY8 Bridge Plugin │ │ │ │ │ │ │ │ /wp-json/igny8/v1/publish │ │ │ │ - Receives content payload │ │ │ │ - Creates/updates WordPress post │ │ │ │ - Handles images, categories, tags │ │ │ │ - Returns post ID and URL │ │ │ └─────────────────────────────────────────────────────────┘ │ └───────────────────────────────────────────────────────────────┘ ``` --- ## API Reference ### Schedule Content ```http POST /api/v1/writer/content/{id}/schedule/ Authorization: Api-Key YOUR_KEY Content-Type: application/json { "scheduled_at": "2026-01-20T14:00:00Z" } ``` ### Reschedule Content (Failed or Any Status) ```http POST /api/v1/writer/content/{id}/reschedule/ Authorization: Api-Key YOUR_KEY Content-Type: application/json { "scheduled_at": "2026-01-20T15:00:00Z" } ``` ### Unschedule Content ```http POST /api/v1/writer/content/{id}/unschedule/ Authorization: Api-Key YOUR_KEY ``` ### Publish Immediately ```http POST /api/v1/publisher/publish Authorization: Api-Key YOUR_KEY Content-Type: application/json { "content_id": 123, "destinations": ["wordpress"] }│ │ └────────────────────────────────────────────────────────┘ │ │ │ │ └───────────────────────────────────┼─────────────────────────────┘ │ ▼ 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)