# 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)
