> ## Documentation Index
> Fetch the complete documentation index at: https://devdocs.aiconnected.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Content Strategist AI

> Developer PRD (Product Requirements Document) Last Updated: January 2, 2026 Target Platform: Dokploy on DigitalOcean Primary Developer Tool: Claude Code Esti...

<Info>
  Normalized for Mintlify from `knowledge-base/aiconnected-apps-and-modules/modules/aiConnected-paper/paper-developer-prd.mdx`.
</Info>

# Content Strategist AI

## Developer PRD (Product Requirements Document)

**Version:** 2.0\
**Last Updated:** January 2, 2026\
**Target Platform:** Dokploy on DigitalOcean\
**Primary Developer Tool:** Claude Code\
**Estimated Build Time:** 1 Weekend (Focused)

***

# Table of Contents

1. [Project Overview](#1-project-overview)
2. [Technology Stack](#2-technology-stack)
3. [System Architecture](#3-system-architecture)
4. [Database Design](#4-database-design)
5. [Authentication & Authorization](#5-authentication--authorization)
6. [API Design](#6-api-design)
7. [Content Generation Pipeline](#7-content-generation-pipeline)
8. [PDF Generation System](#8-pdf-generation-system)
9. [Distribution System](#9-distribution-system)
10. [File Storage System](#10-file-storage-system)
11. [White-Label System](#11-white-label-system)
12. [CSV Import System](#12-csv-import-system)
13. [Scheduled Tasks](#13-scheduled-tasks)
14. [WebSocket Real-Time Updates](#14-websocket-real-time-updates)
15. [Error Handling](#15-error-handling)
16. [Rate Limiting](#16-rate-limiting)
17. [Logging & Monitoring](#17-logging--monitoring)
18. [Security Considerations](#18-security-considerations)
19. [Environment Configuration](#19-environment-configuration)
20. [Deployment](#20-deployment)
21. [Testing Requirements](#21-testing-requirements)

***

# 1. Project Overview

## 1.1 Purpose

Content Strategist AI is a white-label SaaS platform that generates professional thought leadership content for marketing agencies and their clients. The platform produces executive-quality PDF documents with original research, statistics, data visualizations, and professional design.

## 1.2 Key Differentiators

| Aspect         | Our Approach                                           | Competitor Approach                 |
| -------------- | ------------------------------------------------------ | ----------------------------------- |
| Research Depth | Deep, scope-dependent research (20-500 sources)        | Shallow blog scraping (3-5 sources) |
| Output Quality | Consulting-firm quality PDFs                           | Plain text or basic formatting      |
| Design         | Dynamic charts, callout boxes, professional typography | Generic templates                   |
| White-Label    | Complete branding control including custom domains     | Logo swap only                      |
| Scale          | Programmatic generation via CSV                        | Manual UI only                      |

## 1.3 User Types and Descriptions

| User Type     | Description                                  | Primary Actions                                                      | Access Method               |
| ------------- | -------------------------------------------- | -------------------------------------------------------------------- | --------------------------- |
| Super Admin   | Oxford Pierpont staff managing the platform  | Manage agencies, templates, plans, system settings                   | Direct login to admin panel |
| Agency Admin  | Agency owner/manager with full agency access | Manage clients, team, settings, view all content, configure branding | Login via agency domain     |
| Agency Member | Agency staff with limited permissions        | Generate content, review, distribute                                 | Login via agency domain     |
| Client        | End customer viewing their content           | View their content, request generation (limited)                     | Login via agency domain     |

## 1.4 Business Rules

### Seat Limits

* Agencies cannot exceed their plan's seat (client) limit
* Attempting to add a client beyond the limit returns an error
* Deactivated clients do not count toward the limit
* Reactivating a client checks the limit before allowing

### Template Access

* Pro plans: Limited to 5 templates (assigned by Super Admin)
* Enterprise plans: Access to all templates
* New templates can be published by Super Admin
* Agencies cannot access templates not assigned to them

### API Usage

* Pro plans: BYOK (Bring Your Own Key) - agency provides their own Anthropic/Freepik keys
* Enterprise plans: Included credits (\$1,000/month worth)
* Usage is tracked per document generation
* Enterprise agencies receive warnings at 80% usage

### Document Retention

* All documents expire after 3 years from creation
* Expired documents are soft-deleted first, then hard-deleted after 30 days
* Agencies can export their data before expiration
* PDFs are removed from storage when documents are hard-deleted

### Custom Domains

* Only available on Enterprise plans
* Requires DNS verification (TXT record)
* SSL certificates auto-provisioned via Let's Encrypt
* One custom domain per agency

### Image Upload

* Client-uploaded cover images only available on Enterprise plans
* Pro plans use Freepik stock images only
* Uploaded images stored for document retention period
* Maximum image size: 10MB
* Supported formats: JPG, PNG, WebP

***

# 2. Technology Stack

## 2.1 Core Technologies

| Component      | Technology       | Version | Purpose                  | Why This Choice                                   |
| -------------- | ---------------- | ------- | ------------------------ | ------------------------------------------------- |
| Language       | Python           | 3.11+   | Primary backend language | Best AI/ML ecosystem, FastAPI compatibility       |
| Framework      | FastAPI          | 0.109+  | REST API framework       | Async support, automatic OpenAPI docs, type hints |
| ORM            | SQLAlchemy       | 2.0+    | Database ORM             | Industry standard, excellent PostgreSQL support   |
| Migrations     | Alembic          | 1.13+   | Schema migrations        | Native SQLAlchemy integration                     |
| Task Queue     | Celery           | 5.3+    | Async job processing     | Battle-tested, Redis integration                  |
| Message Broker | Redis            | 7+      | Celery broker + caching  | Fast, reliable, multi-purpose                     |
| Database       | PostgreSQL       | 15+     | Primary data store       | JSON support, reliability, performance            |
| PDF Engine     | WeasyPrint       | 60+     | HTML to PDF conversion   | CSS3 support, no external dependencies            |
| HTTP Client    | httpx            | 0.26+   | External API calls       | Async support, modern API                         |
| Validation     | Pydantic         | 2.5+    | Data validation          | Native FastAPI integration                        |
| Auth           | python-jose      | 3.3+    | JWT handling             | Well-maintained, full JWT support                 |
| Passwords      | passlib\[bcrypt] | 1.7+    | Password hashing         | Industry standard bcrypt                          |
| WebSockets     | websockets       | 12+     | Real-time updates        | FastAPI native support                            |

## 2.2 External Services

| Service              | Purpose                                | Required | Fallback                  |
| -------------------- | -------------------------------------- | -------- | ------------------------- |
| Anthropic Claude API | Content generation, research synthesis | Yes      | None (core functionality) |
| Freepik API          | Stock images for covers                | Yes      | Solid color covers        |
| LinkedIn API         | Content distribution                   | Optional | Manual download/upload    |
| Facebook Graph API   | Content distribution                   | Optional | Manual download/upload    |
| Twitter/X API        | Content distribution                   | Optional | Manual download/upload    |
| Google Business API  | Content distribution                   | Optional | Manual download/upload    |

## 2.3 Complete Python Dependencies

```txt theme={null}
# requirements.txt

# Core Framework
fastapi==0.109.0
uvicorn[standard]==0.27.0
starlette==0.35.1

# Database
sqlalchemy==2.0.25
alembic==1.13.1
asyncpg==0.29.0
psycopg2-binary==2.9.9

# Task Queue
celery==5.3.6
redis==5.0.1
flower==2.0.1  # Celery monitoring

# Authentication
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.6

# Validation & Settings
pydantic==2.5.3
pydantic-settings==2.1.0
email-validator==2.1.0

# HTTP & API
httpx==0.26.0
aiohttp==3.9.1
websockets==12.0

# PDF Generation
weasyprint==60.2
Jinja2==3.1.2
cairocffi==1.6.1
Pillow==10.2.0

# Data Processing
pandas==2.1.4
numpy==1.26.3
python-slugify==8.0.1

# Security
cryptography==41.0.7
secrets==1.0.2

# File Handling
aiofiles==23.2.1
python-magic==0.4.27
boto3==1.34.0  # S3 compatible storage

# Charts & Visualization
matplotlib==3.8.2
plotly==5.18.0

# Utilities
python-dateutil==2.8.2
pytz==2023.3
orjson==3.9.10  # Fast JSON
tenacity==8.2.3  # Retry logic
structlog==23.3.0  # Structured logging

# Testing
pytest==7.4.4
pytest-asyncio==0.23.3
pytest-cov==4.1.0
httpx==0.26.0  # For test client
factory-boy==3.3.0
faker==22.0.0

# Development
black==23.12.1
ruff==0.1.9
mypy==1.8.0
pre-commit==3.6.0
```

***

# 3. System Architecture

## 3.1 High-Level Architecture Diagram

```
┌─────────────────────────────────────────────────────────────────────────────────┐
│                                   CLIENTS                                        │
│  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐            │
│  │ Agency UI   │  │ Client UI   │  │ Admin UI    │  │ API Clients │            │
│  │ (React)     │  │ (React)     │  │ (React)     │  │ (CSV/Code)  │            │
│  └──────┬──────┘  └──────┬──────┘  └──────┬──────┘  └──────┬──────┘            │
└─────────┼────────────────┼────────────────┼────────────────┼────────────────────┘
          │                │                │                │
          └────────────────┴────────────────┴────────────────┘
                                    │
                                    ▼
┌─────────────────────────────────────────────────────────────────────────────────┐
│                              LOAD BALANCER                                       │
│                         (Nginx / Traefik / Dokploy)                             │
│  ┌─────────────────────────────────────────────────────────────────────────┐   │
│  │ • SSL Termination          • Domain Routing (*.contentstrategist.com)   │   │
│  │ • Rate Limiting (L7)       • Custom Domain Routing                      │   │
│  │ • WebSocket Upgrade        • Health Checks                              │   │
│  └─────────────────────────────────────────────────────────────────────────┘   │
└────────────────────────────────────┬────────────────────────────────────────────┘
                                     │
                    ┌────────────────┼────────────────┐
                    │                │                │
                    ▼                ▼                ▼
┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐
│    API Server 1      │ │    API Server 2      │ │    API Server N      │
│    (FastAPI)         │ │    (FastAPI)         │ │    (FastAPI)         │
├──────────────────────┤ ├──────────────────────┤ ├──────────────────────┤
│ • REST Endpoints     │ │ • REST Endpoints     │ │ • REST Endpoints     │
│ • WebSocket Handler  │ │ • WebSocket Handler  │ │ • WebSocket Handler  │
│ • Request Validation │ │ • Request Validation │ │ • Request Validation │
│ • Auth Middleware    │ │ • Auth Middleware    │ │ • Auth Middleware    │
│ • Agency Resolution  │ │ • Agency Resolution  │ │ • Agency Resolution  │
└──────────┬───────────┘ └──────────┬───────────┘ └──────────┬───────────┘
           │                        │                        │
           └────────────────────────┼────────────────────────┘
                                    │
        ┌───────────────────────────┼───────────────────────────┐
        │                           │                           │
        ▼                           ▼                           ▼
┌───────────────────┐    ┌───────────────────┐    ┌───────────────────┐
│    PostgreSQL     │    │      Redis        │    │   File Storage    │
│    (Primary DB)   │    │  (Cache/Broker)   │    │   (Local/S3)      │
├───────────────────┤    ├───────────────────┤    ├───────────────────┤
│ • Users           │    │ • Session Cache   │    │ • PDF Documents   │
│ • Agencies        │    │ • Rate Limit Data │    │ • Cover Images    │
│ • Clients         │    │ • Celery Broker   │    │ • Agency Logos    │
│ • Documents       │    │ • WebSocket PubSub│    │ • Client Uploads  │
│ • Templates       │    │ • Job Status      │    │                   │
│ • Scheduled Tasks │    │                   │    │                   │
└───────────────────┘    └─────────┬─────────┘    └───────────────────┘
                                   │
                    ┌──────────────┼──────────────┐
                    │              │              │
                    ▼              ▼              ▼
          ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
          │  Celery Worker  │ │  Celery Worker  │ │  Celery Beat    │
          │  (Generation)   │ │  (Distribution) │ │  (Scheduler)    │
          ├─────────────────┤ ├─────────────────┤ ├─────────────────┤
          │ • Research      │ │ • LinkedIn Post │ │ • Daily Schedule│
          │ • Content Gen   │ │ • Facebook Post │ │ • Cleanup Jobs  │
          │ • PDF Render    │ │ • Twitter Post  │ │ • Usage Reset   │
          │ • Chart Gen     │ │ • GMB Post      │ │ • Expiry Check  │
          └────────┬────────┘ └────────┬────────┘ └─────────────────┘
                   │                   │
                   └─────────┬─────────┘
                             │
                             ▼
          ┌─────────────────────────────────────────────────────┐
          │                  EXTERNAL SERVICES                   │
          ├─────────────────┬─────────────────┬─────────────────┤
          │  Anthropic API  │   Freepik API   │  Social APIs    │
          │  (Claude)       │   (Images)      │  (Distribution) │
          ├─────────────────┼─────────────────┼─────────────────┤
          │ • Research      │ • Stock Photos  │ • LinkedIn      │
          │ • Summarization │ • Search        │ • Facebook      │
          │ • Writing       │ • Download      │ • Twitter/X     │
          │ • Structuring   │                 │ • Google Biz    │
          └─────────────────┴─────────────────┴─────────────────┘
```

## 3.2 Directory Structure

```
content-strategist/
│
├── alembic/                           # Database migrations
│   ├── versions/                      # Migration files
│   │   ├── 001_initial_schema.py
│   │   ├── 002_add_api_keys_table.py
│   │   └── ...
│   ├── env.py                         # Alembic environment config
│   └── script.py.mako                 # Migration template
│
├── app/                               # Main application
│   ├── __init__.py
│   ├── main.py                        # FastAPI application entry point
│   ├── config.py                      # Settings and configuration
│   ├── database.py                    # Database connection and session
│   │
│   ├── models/                        # SQLAlchemy ORM models
│   │   ├── __init__.py                # Export all models
│   │   ├── base.py                    # Base model class with common fields
│   │   ├── user.py                    # User model
│   │   ├── agency.py                  # Agency model
│   │   ├── client.py                  # Client model
│   │   ├── document.py                # Document model
│   │   ├── template.py                # Template model
│   │   ├── plan.py                    # Plan/subscription model
│   │   ├── scheduled_content.py       # Scheduled content model
│   │   ├── generation_job.py          # Generation job tracking
│   │   ├── api_key.py                 # API key model
│   │   └── audit_log.py               # Audit log model
│   │
│   ├── schemas/                       # Pydantic schemas (request/response)
│   │   ├── __init__.py
│   │   ├── auth.py                    # Login, token schemas
│   │   ├── user.py                    # User CRUD schemas
│   │   ├── agency.py                  # Agency CRUD schemas
│   │   ├── client.py                  # Client CRUD schemas
│   │   ├── document.py                # Document schemas
│   │   ├── template.py                # Template schemas
│   │   ├── schedule.py                # Schedule schemas
│   │   ├── generation.py              # Generation request/response
│   │   └── common.py                  # Shared schemas (pagination, etc.)
│   │
│   ├── api/                           # API routes
│   │   ├── __init__.py
│   │   ├── deps.py                    # Shared dependencies
│   │   ├── v1/                        # API version 1
│   │   │   ├── __init__.py
│   │   │   ├── router.py              # Main v1 router aggregator
│   │   │   ├── auth.py                # Authentication endpoints
│   │   │   ├── admin/                 # Super admin endpoints
│   │   │   │   ├── __init__.py
│   │   │   │   ├── agencies.py        # Agency management
│   │   │   │   ├── templates.py       # Template management
│   │   │   │   ├── plans.py           # Plan management
│   │   │   │   └── system.py          # System health, stats
│   │   │   ├── agencies.py            # Agency self-management
│   │   │   ├── team.py                # Team member management
│   │   │   ├── clients.py             # Client CRUD
│   │   │   ├── documents.py           # Document operations
│   │   │   ├── generation.py          # Content generation
│   │   │   ├── distribution.py        # Social distribution
│   │   │   ├── schedule.py            # Scheduled content
│   │   │   ├── templates.py           # Template browsing
│   │   │   └── demo.py                # Demo/trial endpoints
│   │   └── websocket.py               # WebSocket handlers
│   │
│   ├── services/                      # Business logic layer
│   │   ├── __init__.py
│   │   ├── auth_service.py            # Authentication logic
│   │   ├── agency_service.py          # Agency operations
│   │   ├── client_service.py          # Client operations
│   │   ├── document_service.py        # Document CRUD
│   │   ├── generation/                # Generation subsystem
│   │   │   ├── __init__.py
│   │   │   ├── orchestrator.py        # Main generation flow
│   │   │   ├── research_service.py    # Web research
│   │   │   ├── content_service.py     # Content writing
│   │   │   ├── statistics_service.py  # Statistics extraction
│   │   │   ├── chart_service.py       # Chart generation
│   │   │   └── outline_service.py     # Content structuring
│   │   ├── pdf_service.py             # PDF generation
│   │   ├── distribution_service.py    # Social media posting
│   │   ├── storage_service.py         # File storage abstraction
│   │   ├── encryption_service.py      # API key encryption
│   │   ├── image_service.py           # Freepik integration
│   │   └── webhook_service.py         # External webhooks
│   │
│   ├── workers/                       # Celery background tasks
│   │   ├── __init__.py
│   │   ├── celery_app.py              # Celery application config
│   │   ├── generation_tasks.py        # Content generation tasks
│   │   ├── distribution_tasks.py      # Social posting tasks
│   │   ├── scheduled_tasks.py         # Cron-like scheduled tasks
│   │   └── maintenance_tasks.py       # Cleanup, expiry tasks
│   │
│   ├── templates/                     # PDF HTML templates
│   │   ├── base/                      # Base template components
│   │   │   ├── layout.html            # Page layout structure
│   │   │   ├── styles.css             # Base styles
│   │   │   ├── variables.css          # CSS custom properties
│   │   │   └── components/            # Reusable components
│   │   │       ├── cover.html
│   │   │       ├── section.html
│   │   │       ├── callout.html
│   │   │       ├── statistic.html
│   │   │       ├── chart.html
│   │   │       ├── quote.html
│   │   │       ├── table.html
│   │   │       └── footer.html
│   │   ├── executive_01/              # Executive template
│   │   │   ├── template.html
│   │   │   ├── styles.css
│   │   │   └── preview.png
│   │   ├── minimal_02/                # Minimal template
│   │   ├── modern_03/                 # Modern template
│   │   ├── corporate_04/              # Corporate template
│   │   └── bold_05/                   # Bold template
│   │
│   ├── utils/                         # Utility functions
│   │   ├── __init__.py
│   │   ├── slugify.py                 # URL-safe string generation
│   │   ├── validators.py              # Custom validators
│   │   ├── helpers.py                 # Misc helper functions
│   │   ├── constants.py               # Application constants
│   │   ├── exceptions.py              # Custom exceptions
│   │   └── color_utils.py             # Color manipulation
│   │
│   └── middleware/                    # FastAPI middleware
│       ├── __init__.py
│       ├── agency_resolver.py         # White-label domain routing
│       ├── rate_limiter.py            # Request rate limiting
│       ├── request_logging.py         # Request/response logging
│       └── error_handler.py           # Global error handling
│
├── storage/                           # Local file storage (dev/single-server)
│   ├── documents/                     # Generated PDFs
│   ├── covers/                        # Cover images
│   ├── logos/                         # Agency/client logos
│   ├── uploads/                       # Client uploads
│   └── temp/                          # Temporary files
│
├── tests/                             # Test suite
│   ├── __init__.py
│   ├── conftest.py                    # Pytest fixtures
│   ├── factories/                     # Test data factories
│   │   ├── __init__.py
│   │   ├── user_factory.py
│   │   ├── agency_factory.py
│   │   └── ...
│   ├── unit/                          # Unit tests
│   │   ├── test_auth_service.py
│   │   ├── test_generation.py
│   │   └── ...
│   ├── integration/                   # Integration tests
│   │   ├── test_auth_endpoints.py
│   │   ├── test_document_flow.py
│   │   └── ...
│   └── e2e/                           # End-to-end tests
│       └── test_full_generation.py
│
├── scripts/                           # Utility scripts
│   ├── seed_plans.py                  # Initialize plan data
│   ├── seed_templates.py              # Load template files
│   ├── create_super_admin.py          # Create initial admin
│   ├── migrate_data.py                # Data migration helpers
│   └── cleanup_expired.py             # Manual cleanup script
│
├── docker/                            # Docker configuration
│   ├── Dockerfile                     # Main API image
│   ├── Dockerfile.worker              # Celery worker image
│   ├── docker-compose.yml             # Development compose
│   ├── docker-compose.prod.yml        # Production compose
│   └── nginx/                         # Nginx configuration
│       ├── nginx.conf
│       └── sites/
│           └── default.conf
│
├── docs/                              # Documentation
│   ├── API.md                         # API documentation
│   ├── DEPLOYMENT.md                  # Deployment guide
│   └── DEVELOPMENT.md                 # Development setup
│
├── .env.example                       # Environment template
├── .gitignore
├── alembic.ini                        # Alembic configuration
├── pyproject.toml                     # Python project config
├── requirements.txt                   # Python dependencies
├── requirements-dev.txt               # Dev dependencies
└── README.md
```

## 3.3 Request Flow (Detailed)

```
┌─────────────────────────────────────────────────────────────────────────────────┐
│                              REQUEST LIFECYCLE                                   │
└─────────────────────────────────────────────────────────────────────────────────┘

1. CLIENT REQUEST
   │
   │  POST /api/v1/clients/abc123/documents/generate
   │  Headers:
   │    Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
   │    Host: acme.contentstrategist.com
   │    Content-Type: application/json
   │  Body:
   │    {"topic": "AI Implementation", "tone": "professional", ...}
   │
   ▼
2. LOAD BALANCER (Nginx/Traefik)
   │
   │  • SSL termination
   │  • Check rate limits (IP-based, 1000 req/min)
   │  • Route based on Host header
   │  • Forward to available API instance
   │
   ▼
3. FASTAPI MIDDLEWARE STACK
   │
   │  ┌─────────────────────────────────────────────────────────────┐
   │  │ 3a. RequestLoggingMiddleware                                │
   │  │     • Generate request_id (UUID)                            │
   │  │     • Log request start (method, path, headers)             │
   │  │     • Attach request_id to response headers                 │
   │  └─────────────────────────────────────────────────────────────┘
   │                              │
   │  ┌─────────────────────────────────────────────────────────────┐
   │  │ 3b. AgencyResolverMiddleware                                │
   │  │     • Extract Host header                                   │
   │  │     • Check custom_domain table → agency lookup             │
   │  │     • Check subdomain pattern → agency lookup               │
   │  │     • Attach agency to request.state.agency                 │
   │  │     • If no agency found and not admin route → 404          │
   │  └─────────────────────────────────────────────────────────────┘
   │                              │
   │  ┌─────────────────────────────────────────────────────────────┐
   │  │ 3c. RateLimiterMiddleware                                   │
   │  │     • Check Redis for request count (agency + endpoint)     │
   │  │     • If limit exceeded → 429 Too Many Requests             │
   │  │     • Increment counter with TTL                            │
   │  └─────────────────────────────────────────────────────────────┘
   │                              │
   │  ┌─────────────────────────────────────────────────────────────┐
   │  │ 3d. CORSMiddleware                                          │
   │  │     • Validate Origin header                                │
   │  │     • Add CORS headers to response                          │
   │  └─────────────────────────────────────────────────────────────┘
   │
   ▼
4. ROUTE HANDLER (api/v1/generation.py)
   │
   │  @router.post("/clients/{client_id}/documents/generate")
   │  async def generate_document(
   │      client_id: UUID,
   │      request: GenerationRequest,
   │      current_user: User = Depends(get_current_user),
   │      agency: Agency = Depends(get_current_agency),
   │      db: AsyncSession = Depends(get_db)
   │  ):
   │
   ▼
5. DEPENDENCY INJECTION
   │
   │  ┌─────────────────────────────────────────────────────────────┐
   │  │ 5a. get_db()                                                │
   │  │     • Get database session from pool                        │
   │  │     • Yield session                                         │
   │  │     • Close/return session after request                    │
   │  └─────────────────────────────────────────────────────────────┘
   │                              │
   │  ┌─────────────────────────────────────────────────────────────┐
   │  │ 5b. get_current_user()                                      │
   │  │     • Extract Bearer token from Authorization header        │
   │  │     • Decode and validate JWT                               │
   │  │     • Load user from database                               │
   │  │     • Check user is active                                  │
   │  │     • Check user belongs to resolved agency                 │
   │  │     • Return User object or raise 401                       │
   │  └─────────────────────────────────────────────────────────────┘
   │                              │
   │  ┌─────────────────────────────────────────────────────────────┐
   │  │ 5c. get_current_agency()                                    │
   │  │     • Return request.state.agency from middleware           │
   │  │     • Check agency subscription is active                   │
   │  │     • Return Agency object or raise 403                     │
   │  └─────────────────────────────────────────────────────────────┘
   │
   ▼
6. REQUEST VALIDATION (Pydantic)
   │
   │  class GenerationRequest(BaseModel):
   │      topic: str = Field(..., min_length=3, max_length=500)
   │      tone: Literal["professional", "casual", "authoritative"]
   │      template_code: Optional[str]
   │      keywords: Optional[List[str]]
   │      ...
   │
   │  • Automatic validation
   │  • If invalid → 422 Unprocessable Entity with details
   │
   ▼
7. AUTHORIZATION CHECK
   │
   │  • Verify user has permission to create documents
   │  • Verify client belongs to user's agency
   │  • Verify template is available to agency
   │  • If unauthorized → 403 Forbidden
   │
   ▼
8. BUSINESS LOGIC (Service Layer)
   │
   │  ┌─────────────────────────────────────────────────────────────┐
   │  │ generation_service.start_generation()                       │
   │  │                                                             │
   │  │ • Create Document record (status='pending')                 │
   │  │ • Create GenerationJob record                               │
   │  │ • Dispatch Celery task                                      │
   │  │ • Return document_id and job_id                             │
   │  └─────────────────────────────────────────────────────────────┘
   │
   ▼
9. RESPONSE
   │
   │  HTTP 202 Accepted
   │  {
   │      "document_id": "doc_uuid",
   │      "job_id": "job_uuid",
   │      "status": "pending",
   │      "websocket_url": "wss://acme.contentstrategist.com/ws/generation/job_uuid",
   │      "estimated_duration_seconds": 120
   │  }
   │
   ▼
10. BACKGROUND PROCESSING (Celery)
    │
    │  ┌─────────────────────────────────────────────────────────────┐
    │  │ Task: generate_document_task(document_id)                   │
    │  │                                                             │
    │  │ • Runs asynchronously in worker                             │
    │  │ • Updates job progress via Redis pub/sub                    │
    │  │ • Client receives updates via WebSocket                     │
    │  │ • On completion: Update document status, store PDF          │
    │  └─────────────────────────────────────────────────────────────┘
    │
    ▼
11. WEBSOCKET UPDATES (Real-time)
    │
    │  Client connects to: wss://.../ws/generation/job_uuid
    │  
    │  Server sends:
    │  {"step": "researching", "progress": 25, "detail": "Reading 47 sources..."}
    │  {"step": "writing", "progress": 60, "detail": "Composing section 3 of 5..."}
    │  {"step": "complete", "progress": 100, "document_url": "https://..."}
```

***

# 4. Database Design

## 4.1 Entity Relationship Diagram

```
┌─────────────────────────────────────────────────────────────────────────────────┐
│                           ENTITY RELATIONSHIP DIAGRAM                            │
└─────────────────────────────────────────────────────────────────────────────────┘

                                    ┌─────────────┐
                                    │   PLANS     │
                                    ├─────────────┤
                                    │ id (PK)     │
                                    │ name        │
                                    │ price_cents │
                                    │ max_seats   │
                                    │ max_templates
                                    │ ...         │
                                    └──────┬──────┘
                                           │
                                           │ 1:N
                                           │
                                           ▼
┌─────────────┐                    ┌─────────────┐                    ┌─────────────┐
│  API_KEYS   │                    │  AGENCIES   │                    │  TEMPLATES  │
├─────────────┤                    ├─────────────┤                    ├─────────────┤
│ id (PK)     │       N:1         │ id (PK)     │         M:N        │ id (PK)     │
│ agency_id(FK)├──────────────────►│ plan_id(FK) │◄────────┬─────────►│ code        │
│ name        │                    │ name        │         │          │ name        │
│ key_hash    │                    │ slug        │         │          │ html_template
│ scopes[]    │                    │ custom_domain         │          │ css_template│
│ ...         │                    │ colors      │         │          │ ...         │
└─────────────┘                    │ logos       │         │          └─────────────┘
                                   │ oauth_creds │         │
                                   │ ...         │         │
                                   └──────┬──────┘         │
                                          │                │
                    ┌─────────────────────┼────────────────┤
                    │                     │                │
                    │ 1:N                 │ 1:N            │ (junction table)
                    │                     │                │
                    ▼                     ▼                ▼
           ┌─────────────┐       ┌─────────────┐   ┌──────────────────┐
           │   USERS     │       │  CLIENTS    │   │ AGENCY_TEMPLATES │
           ├─────────────┤       ├─────────────┤   ├──────────────────┤
           │ id (PK)     │       │ id (PK)     │   │ agency_id (FK)   │
           │ agency_id(FK)│      │ agency_id(FK)│   │ template_id (FK) │
           │ client_id(FK)├─────►│ company_name│   │ assigned_at      │
           │ email       │       │ contact_*   │   └──────────────────┘
           │ password_hash       │ website_url │
           │ role        │       │ industry    │
           │ ...         │       │ colors      │
           └─────────────┘       │ logos       │
                                 │ oauth_creds │
                                 │ defaults    │
                                 │ ...         │
                                 └──────┬──────┘
                                        │
                         ┌──────────────┼──────────────┐
                         │              │              │
                         │ 1:N          │ 1:N          │ 1:N
                         │              │              │
                         ▼              ▼              ▼
                ┌─────────────┐ ┌─────────────┐ ┌─────────────────┐
                │ DOCUMENTS   │ │ SCHEDULED_  │ │ GENERATION_JOBS │
                ├─────────────┤ │ CONTENT     │ ├─────────────────┤
                │ id (PK)     │ ├─────────────┤ │ id (PK)         │
                │ client_id(FK)│ │ id (PK)     │ │ document_id(FK) │
                │ template_id │ │ client_id(FK)│ │ celery_task_id  │
                │ title       │ │ scheduled_* │ │ current_step    │
                │ topic       │ │ topic       │ │ progress        │
                │ content_json│ │ template_code │ step_timings   │
                │ pdf_url     │ │ status      │ │ ...             │
                │ status      │ │ document_id │ └─────────────────┘
                │ research_*  │ │ ...         │
                │ distribution│ └─────────────┘
                │ ...         │
                └─────────────┘
                        │
                        │ Logged to
                        ▼
                ┌─────────────┐
                │ AUDIT_LOGS  │
                ├─────────────┤
                │ id (PK)     │
                │ user_id     │
                │ agency_id   │
                │ action      │
                │ resource_*  │
                │ changes     │
                │ ip_address  │
                │ ...         │
                └─────────────┘
```

## 4.2 Complete Schema Definitions

### 4.2.1 Plans Table

```sql theme={null}
-- Plans define subscription tiers
-- Seeded by Super Admin, rarely changed

CREATE TABLE plans (
    -- Primary Key
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    
    -- Identification
    name VARCHAR(50) NOT NULL UNIQUE,
    -- Valid values: 'pro_annual', 'pro_monthly', 'enterprise_annual', 'enterprise_monthly'
    
    display_name VARCHAR(100) NOT NULL,
    -- Human-readable: 'Pro (Annual)', 'Enterprise (Monthly)', etc.
    
    description TEXT,
    -- Marketing description for plan selection UI
    
    -- Pricing
    price_cents INTEGER NOT NULL,
    -- Price in cents: 1000000 = $10,000
    
    billing_period VARCHAR(20) NOT NULL CHECK (billing_period IN ('annual', 'monthly')),
    
    -- Limits
    max_seats INTEGER NOT NULL,
    -- Maximum number of clients agency can have
    -- Pro: 50, Enterprise: 200
    
    max_templates INTEGER,
    -- NULL = unlimited (all templates)
    -- Pro: 5, Enterprise: NULL
    
    -- Feature Flags
    custom_domain_allowed BOOLEAN NOT NULL DEFAULT FALSE,
    -- Enterprise only
    
    client_image_upload_allowed BOOLEAN NOT NULL DEFAULT FALSE,
    -- Enterprise only: clients can upload their own cover images
    
    -- API Configuration
    api_key_mode VARCHAR(20) NOT NULL DEFAULT 'byok' CHECK (api_key_mode IN ('byok', 'included')),
    -- 'byok' = Bring Your Own Key (Pro)
    -- 'included' = We provide API credits (Enterprise)
    
    included_api_credits_cents INTEGER NOT NULL DEFAULT 0,
    -- Monthly API credit allowance for 'included' mode
    -- Enterprise: 100000 = $1,000/month
    
    -- Display
    sort_order INTEGER NOT NULL DEFAULT 0,
    -- For ordering in plan selection UI
    
    badge_text VARCHAR(50),
    -- e.g., 'Most Popular', 'Best Value'
    
    -- Status
    is_active BOOLEAN NOT NULL DEFAULT TRUE,
    -- Inactive plans cannot be selected for new agencies
    
    is_visible BOOLEAN NOT NULL DEFAULT TRUE,
    -- Hidden plans don't show in public pricing
    
    -- Timestamps
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);

-- Indexes
CREATE INDEX idx_plans_is_active ON plans(is_active) WHERE is_active = TRUE;
CREATE INDEX idx_plans_sort_order ON plans(sort_order);

-- Seed Data
INSERT INTO plans (
    name, display_name, description, price_cents, billing_period,
    max_seats, max_templates, custom_domain_allowed, client_image_upload_allowed,
    api_key_mode, included_api_credits_cents, sort_order, badge_text
) VALUES
(
    'pro_annual',
    'Pro (Annual)',
    'For growing agencies ready to scale their content operation',
    1000000,  -- $10,000/year
    'annual',
    50,       -- 50 clients
    5,        -- 5 templates
    FALSE,    -- No custom domain
    FALSE,    -- No image upload
    'byok',   -- Bring your own keys
    0,        -- No included credits
    1,
    'Best Value'
),
(
    'pro_monthly',
    'Pro (Monthly)',
    'For growing agencies ready to scale their content operation',
    200000,   -- $2,000/month
    'monthly',
    50,
    5,
    FALSE,
    FALSE,
    'byok',
    0,
    2,
    NULL
),
(
    'enterprise_annual',
    'Enterprise (Annual)',
    'For established agencies requiring full white-label capabilities',
    2000000,  -- $20,000/year
    'annual',
    200,      -- 200 clients
    NULL,     -- All templates
    TRUE,     -- Custom domain
    TRUE,     -- Image upload
    'included',
    100000,   -- $1,000/month in credits
    3,
    'Full Featured'
),
(
    'enterprise_monthly',
    'Enterprise (Monthly)',
    'For established agencies requiring full white-label capabilities',
    500000,   -- $5,000/month
    'monthly',
    200,
    NULL,
    TRUE,
    TRUE,
    'included',
    100000,
    4,
    NULL
);
```

### 4.2.2 Agencies Table

```sql theme={null}
-- Agencies are the primary customers (marketing agencies)
-- Each agency can have multiple clients (seats)

CREATE TABLE agencies (
    -- Primary Key
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    
    -- Plan Relationship
    plan_id UUID NOT NULL REFERENCES plans(id) ON DELETE RESTRICT,
    -- Cannot delete a plan that has agencies
    
    -- ═══════════════════════════════════════════════════════════════
    -- IDENTIFICATION
    -- ═══════════════════════════════════════════════════════════════
    
    name VARCHAR(255) NOT NULL,
    -- Display name: "Acme Marketing Agency"
    
    slug VARCHAR(100) NOT NULL UNIQUE,
    -- URL-safe identifier for subdomains: "acme"
    -- Used for: acme.contentstrategist.com
    -- Constraints: lowercase, alphanumeric + hyphens, no leading/trailing hyphens
    
    -- ═══════════════════════════════════════════════════════════════
    -- CUSTOM DOMAIN (Enterprise Only)
    -- ═══════════════════════════════════════════════════════════════
    
    custom_domain VARCHAR(255) UNIQUE,
    -- e.g., 'content.acmeagency.com'
    -- NULL for Pro plans
    
    custom_domain_verified BOOLEAN NOT NULL DEFAULT FALSE,
    -- TRUE after DNS verification
    
    custom_domain_verification_token VARCHAR(64),
    -- TXT record value for DNS verification
    -- e.g., "content-strategist-verify=abc123xyz..."
    
    custom_domain_verified_at TIMESTAMP WITH TIME ZONE,
    -- When verification succeeded
    
    -- ═══════════════════════════════════════════════════════════════
    -- API KEYS (Encrypted - for BYOK Mode)
    -- ═══════════════════════════════════════════════════════════════
    
    anthropic_api_key_encrypted TEXT,
    -- Encrypted Anthropic API key
    -- Format: encrypted using Fernet symmetric encryption
    
    anthropic_api_key_last4 VARCHAR(4),
    -- Last 4 characters for display: "...abc1"
    
    freepik_api_key_encrypted TEXT,
    -- Encrypted Freepik API key
    
    freepik_api_key_last4 VARCHAR(4),
    
    -- ═══════════════════════════════════════════════════════════════
    -- BRANDING - UI THEME
    -- ═══════════════════════════════════════════════════════════════
    
    color_mode VARCHAR(10) NOT NULL DEFAULT 'light' CHECK (color_mode IN ('light', 'dark')),
    -- Base theme mode
    
    color_accent_1 VARCHAR(7) NOT NULL DEFAULT '#1A1A1A',
    -- Primary accent (headers, primary buttons)
    -- Must be valid hex: #RRGGBB
    
    color_accent_2 VARCHAR(7) NOT NULL DEFAULT '#6B7280',
    -- Secondary accent (secondary elements)
    
    color_accent_3 VARCHAR(7) NOT NULL DEFAULT '#3B82F6',
    -- Highlight accent (links, highlights)
    
    -- ═══════════════════════════════════════════════════════════════
    -- BRANDING - LOGOS
    -- ═══════════════════════════════════════════════════════════════
    
    logo_horizontal_url TEXT,
    -- Main header logo (recommended: 200x50px)
    -- Full URL to stored file
    
    logo_horizontal_width INTEGER,
    logo_horizontal_height INTEGER,
    
    logo_vertical_url TEXT,
    -- Square/stacked logo (recommended: 100x100px)
    
    logo_vertical_width INTEGER,
    logo_vertical_height INTEGER,
    
    logo_round_url TEXT,
    -- Circular avatar logo (recommended: 64x64px)
    
    logo_favicon_url TEXT,
    -- Favicon (recommended: 32x32px)
    
    -- ═══════════════════════════════════════════════════════════════
    -- BRANDING - COMPANY INFO
    -- ═══════════════════════════════════════════════════════════════
    
    company_website VARCHAR(255),
    -- https://acmeagency.com
    
    company_email VARCHAR(255),
    -- contact@acmeagency.com
    
    company_phone VARCHAR(50),
    
    company_address TEXT,
    -- Full mailing address
    
    footer_text TEXT,
    -- Custom footer for PDFs
    -- e.g., "© 2026 Acme Agency. Confidential."
    
    -- ═══════════════════════════════════════════════════════════════
    -- SOCIAL MEDIA URLS (Agency's own pages, not OAuth)
    -- ═══════════════════════════════════════════════════════════════
    
    social_linkedin_url VARCHAR(255),
    social_facebook_url VARCHAR(255),
    social_twitter_url VARCHAR(255),
    social_instagram_url VARCHAR(255),
    
    -- ═══════════════════════════════════════════════════════════════
    -- OAUTH CREDENTIALS (Encrypted JSON)
    -- For posting on behalf of clients
    -- ═══════════════════════════════════════════════════════════════
    
    oauth_linkedin_encrypted TEXT,
    -- JSON structure (encrypted):
    -- {
    --   "client_id": "...",
    --   "client_secret": "...",
    --   "configured_at": "2026-01-01T00:00:00Z"
    -- }
    
    oauth_linkedin_configured BOOLEAN NOT NULL DEFAULT FALSE,
    
    oauth_facebook_encrypted TEXT,
    oauth_facebook_configured BOOLEAN NOT NULL DEFAULT FALSE,
    
    oauth_twitter_encrypted TEXT,
    oauth_twitter_configured BOOLEAN NOT NULL DEFAULT FALSE,
    
    oauth_google_business_encrypted TEXT,
    oauth_google_business_configured BOOLEAN NOT NULL DEFAULT FALSE,
    
    -- ═══════════════════════════════════════════════════════════════
    -- SUBSCRIPTION & BILLING
    -- ═══════════════════════════════════════════════════════════════
    
    subscription_status VARCHAR(20) NOT NULL DEFAULT 'active' 
        CHECK (subscription_status IN ('active', 'past_due', 'canceled', 'suspended', 'trial')),
    
    subscription_started_at TIMESTAMP WITH TIME ZONE,
    -- When current subscription began
    
    subscription_current_period_start TIMESTAMP WITH TIME ZONE,
    -- Start of current billing period
    
    subscription_current_period_end TIMESTAMP WITH TIME ZONE,
    -- End of current billing period
    
    subscription_canceled_at TIMESTAMP WITH TIME ZONE,
    -- When cancellation was requested (still active until period end)
    
    subscription_cancel_at_period_end BOOLEAN NOT NULL DEFAULT FALSE,
    -- If TRUE, subscription ends at period end
    
    -- External billing reference (Stripe, etc.)
    billing_customer_id VARCHAR(255),
    billing_subscription_id VARCHAR(255),
    
    -- ═══════════════════════════════════════════════════════════════
    -- API USAGE TRACKING (Enterprise with included credits)
    -- ═══════════════════════════════════════════════════════════════
    
    api_usage_cents_current_period INTEGER NOT NULL DEFAULT 0,
    -- Usage in current billing period (in cents)
    
    api_usage_period_start TIMESTAMP WITH TIME ZONE,
    api_usage_period_end TIMESTAMP WITH TIME ZONE,
    
    api_usage_warning_sent_at TIMESTAMP WITH TIME ZONE,
    -- When 80% usage warning was sent (NULL if not sent)
    
    -- ═══════════════════════════════════════════════════════════════
    -- SETTINGS & PREFERENCES
    -- ═══════════════════════════════════════════════════════════════
    
    default_timezone VARCHAR(50) NOT NULL DEFAULT 'America/New_York',
    -- For scheduled content
    
    notification_email VARCHAR(255),
    -- Where to send system notifications
    
    webhook_url TEXT,
    -- Optional webhook for generation complete events
    
    webhook_secret VARCHAR(64),
    -- For webhook signature verification
    
    -- ═══════════════════════════════════════════════════════════════
    -- METADATA
    -- ═══════════════════════════════════════════════════════════════
    
    is_active BOOLEAN NOT NULL DEFAULT TRUE,
    -- Soft delete flag
    
    notes TEXT,
    -- Internal notes (Super Admin only)
    
    onboarded_at TIMESTAMP WITH TIME ZONE,
    -- When agency completed onboarding
    
    -- ═══════════════════════════════════════════════════════════════
    -- TIMESTAMPS
    -- ═══════════════════════════════════════════════════════════════
    
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    
    -- ═══════════════════════════════════════════════════════════════
    -- CONSTRAINTS
    -- ═══════════════════════════════════════════════════════════════
    
    CONSTRAINT valid_slug CHECK (slug ~ '^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$'),
    CONSTRAINT valid_hex_accent_1 CHECK (color_accent_1 ~ '^#[0-9A-Fa-f]{6}$'),
    CONSTRAINT valid_hex_accent_2 CHECK (color_accent_2 ~ '^#[0-9A-Fa-f]{6}$'),
    CONSTRAINT valid_hex_accent_3 CHECK (color_accent_3 ~ '^#[0-9A-Fa-f]{6}$')
);

-- Indexes
CREATE INDEX idx_agencies_slug ON agencies(slug);
CREATE INDEX idx_agencies_custom_domain ON agencies(custom_domain) WHERE custom_domain IS NOT NULL;
CREATE INDEX idx_agencies_plan_id ON agencies(plan_id);
CREATE INDEX idx_agencies_subscription_status ON agencies(subscription_status);
CREATE INDEX idx_agencies_is_active ON agencies(is_active) WHERE is_active = TRUE;

-- Full-text search on agency name (for admin search)
CREATE INDEX idx_agencies_name_search ON agencies USING gin(to_tsvector('english', name));
```

### 4.2.3 Users Table

```sql theme={null}
-- Users are people who log into the platform
-- Each user belongs to exactly one agency (except super_admin)
-- Client users also reference a specific client

CREATE TABLE users (
    -- Primary Key
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    
    -- ═══════════════════════════════════════════════════════════════
    -- RELATIONSHIPS
    -- ═══════════════════════════════════════════════════════════════
    
    agency_id UUID REFERENCES agencies(id) ON DELETE CASCADE,
    -- NULL for super_admin users
    -- Required for agency_admin, agency_member, client roles
    
    client_id UUID REFERENCES clients(id) ON DELETE CASCADE,
    -- Only set for 'client' role users
    -- NULL for all other roles
    
    -- ═══════════════════════════════════════════════════════════════
    -- AUTHENTICATION
    -- ═══════════════════════════════════════════════════════════════
    
    email VARCHAR(255) NOT NULL,
    -- Must be unique within agency context
    -- super_admin emails globally unique
    
    password_hash TEXT NOT NULL,
    -- bcrypt hash of password
    -- Format: $2b$12$...
    
    -- ═══════════════════════════════════════════════════════════════
    -- ROLE & PERMISSIONS
    -- ═══════════════════════════════════════════════════════════════
    
    role VARCHAR(20) NOT NULL CHECK (role IN ('super_admin', 'agency_admin', 'agency_member', 'client')),
    
    -- Role descriptions:
    -- super_admin: Oxford Pierpont staff, full system access
    -- agency_admin: Agency owner/manager, full agency access
    -- agency_member: Agency staff, limited to content operations
    -- client: End customer, view-only for their content
    
    -- ═══════════════════════════════════════════════════════════════
    -- PROFILE
    -- ═══════════════════════════════════════════════════════════════
    
    first_name VARCHAR(100),
    last_name VARCHAR(100),
    
    display_name VARCHAR(200) GENERATED ALWAYS AS (
        COALESCE(first_name || ' ' || last_name, first_name, last_name, email)
    ) STORED,
    -- Computed display name for UI
    
    phone VARCHAR(50),
    
    avatar_url TEXT,
    -- URL to profile picture
    
    job_title VARCHAR(100),
    -- e.g., "Content Manager", "Account Executive"
    
    -- ═══════════════════════════════════════════════════════════════
    -- ACCOUNT STATUS
    -- ═══════════════════════════════════════════════════════════════
    
    is_active BOOLEAN NOT NULL DEFAULT TRUE,
    -- Soft delete / disable flag
    
    deactivated_at TIMESTAMP WITH TIME ZONE,
    deactivated_by UUID REFERENCES users(id),
    deactivation_reason TEXT,
    
    -- ═══════════════════════════════════════════════════════════════
    -- EMAIL VERIFICATION
    -- ═══════════════════════════════════════════════════════════════
    
    is_email_verified BOOLEAN NOT NULL DEFAULT FALSE,
    
    email_verification_token VARCHAR(64),
    -- Random token sent in verification email
    
    email_verification_sent_at TIMESTAMP WITH TIME ZONE,
    email_verified_at TIMESTAMP WITH TIME ZONE,
    
    -- ═══════════════════════════════════════════════════════════════
    -- PASSWORD RESET
    -- ═══════════════════════════════════════════════════════════════
    
    password_reset_token VARCHAR(64),
    password_reset_expires_at TIMESTAMP WITH TIME ZONE,
    password_changed_at TIMESTAMP WITH TIME ZONE,
    
    -- ═══════════════════════════════════════════════════════════════
    -- SESSION & SECURITY
    -- ═══════════════════════════════════════════════════════════════
    
    last_login_at TIMESTAMP WITH TIME ZONE,
    last_login_ip VARCHAR(45),  -- Supports IPv6
    last_login_user_agent TEXT,
    
    failed_login_attempts INTEGER NOT NULL DEFAULT 0,
    locked_until TIMESTAMP WITH TIME ZONE,
    -- Account locked after too many failed attempts
    
    -- ═══════════════════════════════════════════════════════════════
    -- PREFERENCES
    -- ═══════════════════════════════════════════════════════════════
    
    timezone VARCHAR(50) DEFAULT 'America/New_York',
    locale VARCHAR(10) DEFAULT 'en-US',
    
    notification_preferences JSONB NOT NULL DEFAULT '{"email": true, "browser": true}'::jsonb,
    -- {
    --   "email": true,
    --   "browser": true,
    --   "generation_complete": true,
    --   "distribution_complete": true,
    --   "weekly_summary": false
    -- }
    
    -- ═══════════════════════════════════════════════════════════════
    -- TIMESTAMPS
    -- ═══════════════════════════════════════════════════════════════
    
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    
    -- ═══════════════════════════════════════════════════════════════
    -- CONSTRAINTS
    -- ═══════════════════════════════════════════════════════════════
    
    -- Email unique within agency (or globally for super_admin)
    CONSTRAINT unique_email_per_agency UNIQUE (email, agency_id),
    
    -- Role-based relationship requirements
    CONSTRAINT valid_role_relationships CHECK (
        CASE role
            WHEN 'super_admin' THEN agency_id IS NULL AND client_id IS NULL
            WHEN 'agency_admin' THEN agency_id IS NOT NULL AND client_id IS NULL
            WHEN 'agency_member' THEN agency_id IS NOT NULL AND client_id IS NULL
            WHEN 'client' THEN agency_id IS NOT NULL AND client_id IS NOT NULL
            ELSE FALSE
        END
    )
);

-- Indexes
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_agency_id ON users(agency_id) WHERE agency_id IS NOT NULL;
CREATE INDEX idx_users_client_id ON users(client_id) WHERE client_id IS NOT NULL;
CREATE INDEX idx_users_role ON users(role);
CREATE INDEX idx_users_is_active ON users(is_active) WHERE is_active = TRUE;

-- Unique indexes for tokens (partial to ignore NULLs)
CREATE UNIQUE INDEX idx_users_email_verification_token 
    ON users(email_verification_token) 
    WHERE email_verification_token IS NOT NULL;

CREATE UNIQUE INDEX idx_users_password_reset_token 
    ON users(password_reset_token) 
    WHERE password_reset_token IS NOT NULL;
```

### 4.2.4 Clients Table

```sql theme={null}
-- Clients are the end customers of agencies
-- Each client belongs to one agency
-- Clients inherit branding from agency but can override

CREATE TABLE clients (
    -- Primary Key
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    
    -- ═══════════════════════════════════════════════════════════════
    -- PARENT AGENCY
    -- ═══════════════════════════════════════════════════════════════
    
    agency_id UUID NOT NULL REFERENCES agencies(id) ON DELETE CASCADE,
    
    -- ═══════════════════════════════════════════════════════════════
    -- BASIC INFORMATION
    -- ═══════════════════════════════════════════════════════════════
    
    company_name VARCHAR(255) NOT NULL,
    -- "Acme Corporation"
    
    company_slug VARCHAR(100),
    -- URL-safe identifier: "acme-corp"
    -- Auto-generated from company_name if not provided
    
    contact_name VARCHAR(255),
    -- Primary contact person
    
    contact_email VARCHAR(255),
    contact_phone VARCHAR(50),
    contact_title VARCHAR(100),
    -- "VP of Marketing"
    
    -- ═══════════════════════════════════════════════════════════════
    -- BUSINESS INFORMATION
    -- ═══════════════════════════════════════════════════════════════
    
    website_url TEXT,
    -- https://acmecorp.com
    
    industry VARCHAR(255),
    -- Free text (not dropdown): "Healthcare Technology"
    
    company_size VARCHAR(50),
    -- "1-10", "11-50", "51-200", "201-500", "500+"
    
    company_description TEXT,
    -- Brief description for AI context
    
    -- ═══════════════════════════════════════════════════════════════
    -- BRANDING - LOGOS (Override agency defaults)
    -- ═══════════════════════════════════════════════════════════════
    
    logo_horizontal_url TEXT,
    logo_horizontal_width INTEGER,
    logo_horizontal_height INTEGER,
    
    logo_vertical_url TEXT,
    logo_vertical_width INTEGER,
    logo_vertical_height INTEGER,
    
    logo_round_url TEXT,
    
    -- ═══════════════════════════════════════════════════════════════
    -- BRANDING - COLORS (Override agency defaults)
    -- NULL = inherit from agency
    -- ═══════════════════════════════════════════════════════════════
    
    color_accent_1 VARCHAR(7),
    color_accent_2 VARCHAR(7),
    color_accent_3 VARCHAR(7),
    
    -- ═══════════════════════════════════════════════════════════════
    -- BRANDING - FOOTER
    -- ═══════════════════════════════════════════════════════════════
    
    footer_text TEXT,
    -- Override agency footer for this client's PDFs
    
    -- ═══════════════════════════════════════════════════════════════
    -- SOCIAL MEDIA OAUTH (Per-Client Tokens)
    -- These are the tokens for posting TO the client's accounts
    -- ═══════════════════════════════════════════════════════════════
    
    oauth_linkedin_encrypted TEXT,
    -- Encrypted JSON:
    -- {
    --   "access_token": "...",
    --   "refresh_token": "...",
    --   "expires_at": "2026-01-01T00:00:00Z",
    --   "organization_id": "...",  // For company pages
    --   "organization_name": "Acme Corporation"
    -- }
    
    oauth_linkedin_connected BOOLEAN NOT NULL DEFAULT FALSE,
    oauth_linkedin_connected_at TIMESTAMP WITH TIME ZONE,
    oauth_linkedin_page_name VARCHAR(255),
    -- Display: "Connected to: Acme Corporation"
    
    oauth_facebook_encrypted TEXT,
    oauth_facebook_connected BOOLEAN NOT NULL DEFAULT FALSE,
    oauth_facebook_connected_at TIMESTAMP WITH TIME ZONE,
    oauth_facebook_page_name VARCHAR(255),
    
    oauth_twitter_encrypted TEXT,
    oauth_twitter_connected BOOLEAN NOT NULL DEFAULT FALSE,
    oauth_twitter_connected_at TIMESTAMP WITH TIME ZONE,
    oauth_twitter_handle VARCHAR(100),
    -- @AcmeCorp
    
    oauth_google_business_encrypted TEXT,
    oauth_google_business_connected BOOLEAN NOT NULL DEFAULT FALSE,
    oauth_google_business_connected_at TIMESTAMP WITH TIME ZONE,
    oauth_google_business_location_name VARCHAR(255),
    
    -- ═══════════════════════════════════════════════════════════════
    -- CONTENT DEFAULTS
    -- Pre-filled when generating content for this client
    -- ═══════════════════════════════════════════════════════════════
    
    default_tone VARCHAR(20) DEFAULT 'professional' 
        CHECK (default_tone IN ('professional', 'casual', 'authoritative')),
    
    related_services TEXT[],
    -- Services to promote in content
    -- ["Cloud Migration", "Data Analytics", "AI Consulting"]
    
    target_keywords TEXT[],
    -- Default keywords for SEO
    -- ["enterprise AI", "digital transformation", "cloud strategy"]
    
    additional_context TEXT,
    -- Permanent context for all content
    -- "Focus on Fortune 500 audience. Avoid competitor mentions."
    
    brand_voice_notes TEXT,
    -- Notes about brand voice
    -- "Formal but approachable. Use data-driven language."
    
    -- ═══════════════════════════════════════════════════════════════
    -- CONTENT STATISTICS
    -- ═══════════════════════════════════════════════════════════════
    
    total_documents_generated INTEGER NOT NULL DEFAULT 0,
    total_documents_distributed INTEGER NOT NULL DEFAULT 0,
    last_document_generated_at TIMESTAMP WITH TIME ZONE,
    last_document_distributed_at TIMESTAMP WITH TIME ZONE,
    
    -- ═══════════════════════════════════════════════════════════════
    -- STATUS
    -- ═══════════════════════════════════════════════════════════════
    
    is_active BOOLEAN NOT NULL DEFAULT TRUE,
    -- Soft delete flag
    
    deactivated_at TIMESTAMP WITH TIME ZONE,
    deactivation_reason TEXT,
    
    -- ═══════════════════════════════════════════════════════════════
    -- TIMESTAMPS
    -- ═══════════════════════════════════════════════════════════════
    
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    
    -- ═══════════════════════════════════════════════════════════════
    -- CONSTRAINTS
    -- ═══════════════════════════════════════════════════════════════
    
    CONSTRAINT valid_hex_client_accent_1 CHECK (
        color_accent_1 IS NULL OR color_accent_1 ~ '^#[0-9A-Fa-f]{6}$'
    ),
    CONSTRAINT valid_hex_client_accent_2 CHECK (
        color_accent_2 IS NULL OR color_accent_2 ~ '^#[0-9A-Fa-f]{6}$'
    ),
    CONSTRAINT valid_hex_client_accent_3 CHECK (
        color_accent_3 IS NULL OR color_accent_3 ~ '^#[0-9A-Fa-f]{6}$'
    ),
    
    -- Unique slug per agency
    CONSTRAINT unique_client_slug_per_agency UNIQUE (agency_id, company_slug)
);

-- Indexes
CREATE INDEX idx_clients_agency_id ON clients(agency_id);
CREATE INDEX idx_clients_company_name ON clients(company_name);
CREATE INDEX idx_clients_company_slug ON clients(company_slug);
CREATE INDEX idx_clients_is_active ON clients(is_active) WHERE is_active = TRUE;
CREATE INDEX idx_clients_industry ON clients(industry) WHERE industry IS NOT NULL;

-- Full-text search
CREATE INDEX idx_clients_search ON clients 
    USING gin(to_tsvector('english', company_name || ' ' || COALESCE(industry, '')));
```

### 4.2.5 Templates Table

```sql theme={null}
-- Templates define PDF design layouts
-- Managed by Super Admin
-- Assigned to agencies based on plan

CREATE TABLE templates (
    -- Primary Key
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    
    -- ═══════════════════════════════════════════════════════════════
    -- IDENTIFICATION
    -- ═══════════════════════════════════════════════════════════════
    
    code VARCHAR(50) NOT NULL UNIQUE,
    -- Machine identifier: 'EXEC_01', 'MINIMAL_02', 'MODERN_03'
    -- Used in CSV uploads and API calls
    
    name VARCHAR(255) NOT NULL,
    -- Display name: 'Executive Professional'
    
    description TEXT,
    -- Description for template selection UI
    
    -- ═══════════════════════════════════════════════════════════════
    -- TEMPLATE CONTENT
    -- ═══════════════════════════════════════════════════════════════
    
    html_template TEXT NOT NULL,
    -- Main HTML structure with Jinja2 placeholders
    -- {{ title }}, {{ sections }}, {% for stat in statistics %}
    
    css_template TEXT NOT NULL,
    -- Stylesheet with CSS custom properties for colors
    -- var(--color-primary), var(--color-secondary)
    
    -- ═══════════════════════════════════════════════════════════════
    -- TEMPLATE CAPABILITIES
    -- ═══════════════════════════════════════════════════════════════
    
    supports_charts BOOLEAN NOT NULL DEFAULT TRUE,
    -- Template has chart placeholders
    
    supports_statistics BOOLEAN NOT NULL DEFAULT TRUE,
    -- Template has stat callout boxes
    
    supports_quotes BOOLEAN NOT NULL DEFAULT TRUE,
    -- Template has pull quote styling
    
    supports_tables BOOLEAN NOT NULL DEFAULT TRUE,
    -- Template has table styling
    
    max_sections INTEGER DEFAULT 10,
    -- Recommended maximum sections
    
    -- ═══════════════════════════════════════════════════════════════
    -- PREVIEW & DISPLAY
    -- ═══════════════════════════════════════════════════════════════
    
    preview_image_url TEXT,
    -- Thumbnail image for template selection
    -- Recommended: 400x520px (letter aspect ratio)
    
    preview_pdf_url TEXT,
    -- Sample PDF for preview
    
    -- ═══════════════════════════════════════════════════════════════
    -- CATEGORIZATION
    -- ═══════════════════════════════════════════════════════════════
    
    category VARCHAR(50),
    -- 'executive', 'minimal', 'modern', 'corporate', 'creative'
    
    tags TEXT[],
    -- ['professional', 'data-heavy', 'minimal', 'bold']
    
    -- ═══════════════════════════════════════════════════════════════
    -- AVAILABILITY
    -- ═══════════════════════════════════════════════════════════════
    
    is_active BOOLEAN NOT NULL DEFAULT TRUE,
    -- Available for use
    
    is_premium BOOLEAN NOT NULL DEFAULT FALSE,
    -- TRUE = Enterprise only
    -- FALSE = Available to all plans (subject to template limit)
    
    is_default BOOLEAN NOT NULL DEFAULT FALSE,
    -- Default template if none specified
    -- Only one template should be default
    
    -- ═══════════════════════════════════════════════════════════════
    -- METADATA
    -- ═══════════════════════════════════════════════════════════════
    
    version INTEGER NOT NULL DEFAULT 1,
    -- Increment when template updated
    
    author VARCHAR(100),
    -- Designer name
    
    release_notes TEXT,
    -- What's new in this version
    
    sort_order INTEGER NOT NULL DEFAULT 0,
    -- Display order in template selection
    
    -- ═══════════════════════════════════════════════════════════════
    -- TIMESTAMPS
    -- ═══════════════════════════════════════════════════════════════
    
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    published_at TIMESTAMP WITH TIME ZONE
    -- When template was made available
);

-- Indexes
CREATE UNIQUE INDEX idx_templates_code ON templates(code);
CREATE INDEX idx_templates_is_active ON templates(is_active) WHERE is_active = TRUE;
CREATE INDEX idx_templates_is_premium ON templates(is_premium);
CREATE INDEX idx_templates_category ON templates(category);
CREATE INDEX idx_templates_sort_order ON templates(sort_order);

-- Ensure only one default template
CREATE UNIQUE INDEX idx_templates_single_default 
    ON templates(is_default) 
    WHERE is_default = TRUE;
```

### 4.2.6 Agency Templates Junction Table

```sql theme={null}
-- Junction table for agency template assignments
-- Pro agencies get specific templates assigned
-- Enterprise agencies have access to all (no rows needed, checked by plan)

CREATE TABLE agency_templates (
    -- Composite Primary Key
    agency_id UUID NOT NULL REFERENCES agencies(id) ON DELETE CASCADE,
    template_id UUID NOT NULL REFERENCES templates(id) ON DELETE CASCADE,
    
    PRIMARY KEY (agency_id, template_id),
    
    -- ═══════════════════════════════════════════════════════════════
    -- ASSIGNMENT INFO
    -- ═══════════════════════════════════════════════════════════════
    
    assigned_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    
    assigned_by UUID REFERENCES users(id),
    -- Super Admin who assigned the template
    
    notes TEXT
    -- Why this template was assigned
);

-- Index for looking up agency's templates
CREATE INDEX idx_agency_templates_agency_id ON agency_templates(agency_id);
```

### 4.2.7 Documents Table

```sql theme={null}
-- Documents are generated content pieces
-- Central to the application

CREATE TABLE documents (
    -- Primary Key
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    
    -- ═══════════════════════════════════════════════════════════════
    -- RELATIONSHIPS
    -- ═══════════════════════════════════════════════════════════════
    
    client_id UUID NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
    
    template_id UUID REFERENCES templates(id) ON DELETE SET NULL,
    -- NULL if template was deleted
    
    created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
    -- Who initiated generation
    
    scheduled_content_id UUID REFERENCES scheduled_content(id) ON DELETE SET NULL,
    -- If created from scheduled content
    
    -- ═══════════════════════════════════════════════════════════════
    -- CONTENT IDENTITY
    -- ═══════════════════════════════════════════════════════════════
    
    title VARCHAR(500) NOT NULL,
    -- Generated title: "AI Implementation: A Strategic Guide for Enterprise Leaders"
    
    subtitle VARCHAR(500),
    -- Optional subtitle
    
    topic VARCHAR(500) NOT NULL,
    -- Original topic input: "AI implementation strategies for mid-market companies"
    
    slug VARCHAR(200),
    -- URL-safe identifier: "ai-implementation-strategic-guide"
    
    -- ═══════════════════════════════════════════════════════════════
    -- GENERATED CONTENT (Structured JSON)
    -- ═══════════════════════════════════════════════════════════════
    
    content_json JSONB NOT NULL DEFAULT '{}'::jsonb,
    /*
    {
        "version": 1,
        "title": "AI Implementation: A Strategic Guide",
        "subtitle": "How Forward-Thinking Companies Are Winning with Artificial Intelligence",
        "summary": "Executive summary paragraph...",
        
        "sections": [
            {
                "number": 1,
                "label": "Strategy 1:",
                "title": "Start with High-Impact, Low-Risk Use Cases",
                "lead": "Bold opening statement...",
                "paragraphs": [
                    "First paragraph content...",
                    "Second paragraph content..."
                ],
                "subheadings": [
                    {
                        "title": "Identifying Quick Wins",
                        "content": "Subheading content..."
                    }
                ],
                "callout": {
                    "type": "tip",
                    "content": "Pro tip: Start with customer service..."
                }
            }
        ],
        
        "statistics": [
            {
                "id": "stat_1",
                "value": "73%",
                "label": "of companies report exceeding ROI expectations",
                "source": "McKinsey Digital Survey 2025",
                "source_url": "https://...",
                "type": "percentage",
                "context": "Supporting context..."
            }
        ],
        
        "charts": [
            {
                "id": "chart_1",
                "type": "bar",
                "title": "AI Investment by Industry",
                "data": {
                    "labels": ["Healthcare", "Finance", "Retail"],
                    "values": [45, 62, 38]
                },
                "source": "Industry Report 2025"
            }
        ],
        
        "quotes": [
            {
                "id": "quote_1",
                "text": "AI is not just a technology...",
                "attribution": "Satya Nadella, CEO Microsoft",
                "source": "Interview, 2025"
            }
        ],
        
        "conclusion": {
            "title": "The Bottom Line",
            "lead": "Concluding statement...",
            "content": "Conclusion paragraphs...",
            "cta": "Contact us to discuss your AI strategy."
        },
        
        "metadata": {
            "word_count": 2500,
            "estimated_read_time_minutes": 12,
            "generated_at": "2026-01-02T10:30:00Z"
        }
    }
    */
    
    -- ═══════════════════════════════════════════════════════════════
    -- GENERATED FILES
    -- ═══════════════════════════════════════════════════════════════
    
    pdf_url TEXT,
    -- Public URL: https://authapi.net/files/agency-slug/doc-id.pdf
    
    pdf_file_path TEXT,
    -- Internal path: /storage/documents/agency-id/client-id/doc-id.pdf
    
    pdf_file_size_bytes INTEGER,
    
    pdf_page_count INTEGER,
    
    cover_image_url TEXT,
    -- Cover image used
    
    cover_image_source VARCHAR(20) CHECK (cover_image_source IN ('freepik', 'uploaded', 'generated', 'none')),
    
    cover_image_freepik_id VARCHAR(100),
    -- For attribution if using Freepik
    
    -- ═══════════════════════════════════════════════════════════════
    -- GENERATION INPUT (What was requested)
    -- ═══════════════════════════════════════════════════════════════
    
    input_tone VARCHAR(20),
    input_industry VARCHAR(255),
    input_keywords TEXT[],
    input_related_services TEXT[],
    input_custom_direction TEXT,
    input_additional_context TEXT,
    input_website_url TEXT,
    
    -- ═══════════════════════════════════════════════════════════════
    -- RESEARCH DATA (For transparency)
    -- ═══════════════════════════════════════════════════════════════
    
    research_sources JSONB DEFAULT '[]'::jsonb,
    /*
    [
        {
            "url": "https://example.com/article",
            "title": "Article Title",
            "domain": "example.com",
            "snippet": "Relevant excerpt...",
            "accessed_at": "2026-01-02T10:15:00Z",
            "relevance_score": 0.85
        }
    ]
    */
    
    research_source_count INTEGER DEFAULT 0,
    -- Number of sources consulted
    
    research_duration_seconds INTEGER,
    -- How long research took
    
    -- ═══════════════════════════════════════════════════════════════
    -- STATUS & WORKFLOW
    -- ═══════════════════════════════════════════════════════════════
    
    status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN (
        'pending',      -- Queued, not started
        'generating',   -- In progress
        'ready',        -- Complete, awaiting review/distribution
        'distributed',  -- Published to social channels
        'failed'        -- Generation failed
    )),
    
    -- ═══════════════════════════════════════════════════════════════
    -- GENERATION TIMING
    -- ═══════════════════════════════════════════════════════════════
    
    generation_queued_at TIMESTAMP WITH TIME ZONE,
    generation_started_at TIMESTAMP WITH TIME ZONE,
    generation_completed_at TIMESTAMP WITH TIME ZONE,
    generation_duration_seconds INTEGER,
    
    -- ═══════════════════════════════════════════════════════════════
    -- GENERATION ERROR (if failed)
    -- ═══════════════════════════════════════════════════════════════
    
    generation_error_code VARCHAR(20),
    -- GEN_001, GEN_002, etc.
    
    generation_error_message TEXT,
    -- Human-readable error
    
    generation_error_step VARCHAR(50),
    -- Which step failed
    
    generation_retry_count INTEGER DEFAULT 0,
    -- How many times generation was retried
    
    -- ═══════════════════════════════════════════════════════════════
    -- DISTRIBUTION
    -- ═══════════════════════════════════════════════════════════════
    
    distributed_at TIMESTAMP WITH TIME ZONE,
    distributed_by_user_id UUID REFERENCES users(id),
    
    distribution_channels JSONB DEFAULT '{}'::jsonb,
    /*
    {
        "linkedin": true,
        "facebook": true,
        "twitter": false,
        "google_business": false
    }
    */
    
    distribution_results JSONB DEFAULT '{}'::jsonb,
    /*
    {
        "linkedin": {
            "success": true,
            "post_id": "urn:li:share:123456789",
            "post_url": "https://linkedin.com/feed/update/...",
            "posted_at": "2026-01-02T11:00:00Z"
        },
        "facebook": {
            "success": false,
            "error_code": "OAUTH_EXPIRED",
            "error_message": "Access token expired"
        }
    }
    */
    
    -- ═══════════════════════════════════════════════════════════════
    -- API USAGE TRACKING
    -- ═══════════════════════════════════════════════════════════════
    
    api_tokens_input INTEGER DEFAULT 0,
    -- Input tokens used
    
    api_tokens_output INTEGER DEFAULT 0,
    -- Output tokens used
    
    api_cost_cents INTEGER DEFAULT 0,
    -- Estimated cost in cents
    
    -- ═══════════════════════════════════════════════════════════════
    -- TIMESTAMPS & RETENTION
    -- ═══════════════════════════════════════════════════════════════
    
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    
    expires_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT (NOW() + INTERVAL '3 years'),
    -- Document retention: 3 years
    
    deleted_at TIMESTAMP WITH TIME ZONE
    -- Soft delete timestamp
);

-- Indexes
CREATE INDEX idx_documents_client_id ON documents(client_id);
CREATE INDEX idx_documents_status ON documents(status);
CREATE INDEX idx_documents_created_at ON documents(created_at DESC);
CREATE INDEX idx_documents_expires_at ON documents(expires_at);
CREATE INDEX idx_documents_template_id ON documents(template_id);
CREATE INDEX idx_documents_created_by ON documents(created_by_user_id);

-- Partial index for active documents (common query)
CREATE INDEX idx_documents_active ON documents(client_id, created_at DESC) 
    WHERE status != 'failed' AND deleted_at IS NULL;

-- Index for cleanup job
CREATE INDEX idx_documents_expired ON documents(expires_at)
    WHERE deleted_at IS NULL;

-- Full-text search on title and topic
CREATE INDEX idx_documents_search ON documents 
    USING gin(to_tsvector('english', title || ' ' || topic));
```

### 4.2.8 Scheduled Content Table

```sql theme={null}
-- Scheduled content for automated generation
-- Uploaded via CSV or created individually

CREATE TABLE scheduled_content (
    -- Primary Key
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    
    -- ═══════════════════════════════════════════════════════════════
    -- RELATIONSHIPS
    -- ═══════════════════════════════════════════════════════════════
    
    client_id UUID NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
    
    created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
    -- Who scheduled this content
    
    -- ═══════════════════════════════════════════════════════════════
    -- SCHEDULE
    -- ═══════════════════════════════════════════════════════════════
    
    scheduled_date DATE NOT NULL,
    -- Date to generate: 2026-01-15
    
    scheduled_time TIME NOT NULL DEFAULT '09:00:00',
    -- Time to generate: 09:00:00
    
    timezone VARCHAR(50) NOT NULL DEFAULT 'America/New_York',
    -- Timezone for scheduling
    
    -- Computed scheduled datetime (for queries)
    scheduled_at TIMESTAMP WITH TIME ZONE GENERATED ALWAYS AS (
        (scheduled_date + scheduled_time) AT TIME ZONE timezone
    ) STORED,
    
    -- ═══════════════════════════════════════════════════════════════
    -- CONTENT SETTINGS
    -- ═══════════════════════════════════════════════════════════════
    
    topic VARCHAR(500) NOT NULL,
    -- Content topic
    
    template_code VARCHAR(50),
    -- Template to use (references templates.code)
    
    tone VARCHAR(20) DEFAULT 'professional' 
        CHECK (tone IN ('professional', 'casual', 'authoritative')),
    
    keywords TEXT[],
    related_services TEXT[],
    custom_direction TEXT,
    additional_context TEXT,
    
    -- ═══════════════════════════════════════════════════════════════
    -- AUTO-DISTRIBUTION SETTINGS
    -- ═══════════════════════════════════════════════════════════════
    
    auto_distribute BOOLEAN NOT NULL DEFAULT FALSE,
    -- If TRUE, distribute immediately after generation
    
    distribution_channels JSONB DEFAULT '{}'::jsonb,
    -- {"linkedin": true, "facebook": true, ...}
    
    -- ═══════════════════════════════════════════════════════════════
    -- STATUS
    -- ═══════════════════════════════════════════════════════════════
    
    status VARCHAR(20) NOT NULL DEFAULT 'pending' CHECK (status IN (
        'pending',      -- Waiting for scheduled time
        'processing',   -- Currently generating
        'completed',    -- Successfully generated
        'failed',       -- Generation failed
        'canceled'      -- Manually canceled
    )),
    
    -- ═══════════════════════════════════════════════════════════════
    -- RESULT
    -- ═══════════════════════════════════════════════════════════════
    
    document_id UUID REFERENCES documents(id) ON DELETE SET NULL,
    -- Created document (if successful)
    
    processed_at TIMESTAMP WITH TIME ZONE,
    -- When generation was attempted
    
    error_message TEXT,
    -- Error if failed
    
    retry_count INTEGER NOT NULL DEFAULT 0,
    -- Number of retry attempts
    
    -- ═══════════════════════════════════════════════════════════════
    -- IMPORT METADATA
    -- ═══════════════════════════════════════════════════════════════
    
    import_batch_id UUID,
    -- If imported via CSV, batch identifier
    
    import_row_number INTEGER,
    -- Row number in CSV
    
    -- ═══════════════════════════════════════════════════════════════
    -- TIMESTAMPS
    -- ═══════════════════════════════════════════════════════════════
    
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);

-- Indexes
CREATE INDEX idx_scheduled_content_client_id ON scheduled_content(client_id);
CREATE INDEX idx_scheduled_content_scheduled_at ON scheduled_content(scheduled_at);
CREATE INDEX idx_scheduled_content_status ON scheduled_content(status);
CREATE INDEX idx_scheduled_content_import_batch ON scheduled_content(import_batch_id) 
    WHERE import_batch_id IS NOT NULL;

-- Index for scheduler query (find pending items ready to process)
CREATE INDEX idx_scheduled_content_pending_ready ON scheduled_content(scheduled_at)
    WHERE status = 'pending';
```

### 4.2.9 Generation Jobs Table

```sql theme={null}
-- Tracks real-time progress of document generation
-- One job per document generation attempt

CREATE TABLE generation_jobs (
    -- Primary Key
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    
    -- ═══════════════════════════════════════════════════════════════
    -- RELATIONSHIPS
    -- ═══════════════════════════════════════════════════════════════
    
    document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
    
    -- ═══════════════════════════════════════════════════════════════
    -- CELERY TASK INFO
    -- ═══════════════════════════════════════════════════════════════
    
    celery_task_id VARCHAR(255),
    -- Celery task UUID for status lookup
    
    worker_id VARCHAR(100),
    -- Which worker is processing
    
    -- ═══════════════════════════════════════════════════════════════
    -- PROGRESS TRACKING
    -- ═══════════════════════════════════════════════════════════════
    
    current_step VARCHAR(50),
    -- Current step ID: 'researching', 'writing', 'rendering'
    
    current_step_label VARCHAR(100),
    -- Human-readable: 'Researching Keywords'
    
    current_step_detail TEXT,
    -- Detail text: 'Reading 47 sources...'
    
    steps_completed TEXT[] DEFAULT '{}',
    -- List of completed step IDs
    
    progress_percent INTEGER NOT NULL DEFAULT 0 
        CHECK (progress_percent >= 0 AND progress_percent <= 100),
    
    -- ═══════════════════════════════════════════════════════════════
    -- STEP TIMINGS (Performance Analysis)
    -- ═══════════════════════════════════════════════════════════════
    
    step_timings JSONB DEFAULT '{}'::jsonb,
    /*
    {
        "topic_analysis": {
            "started_at": "2026-01-02T10:30:00Z",
            "completed_at": "2026-01-02T10:30:05Z",
            "duration_ms": 5000
        },
        "web_research": {
            "started_at": "2026-01-02T10:30:05Z",
            "completed_at": "2026-01-02T10:31:30Z",
            "duration_ms": 85000,
            "metadata": {
                "sources_found": 127,
                "sources_used": 47
            }
        }
    }
    */
    
    -- ═══════════════════════════════════════════════════════════════
    -- OVERALL TIMING
    -- ═══════════════════════════════════════════════════════════════
    
    queued_at TIMESTAMP WITH TIME ZONE,
    started_at TIMESTAMP WITH TIME ZONE,
    completed_at TIMESTAMP WITH TIME ZONE,
    
    total_duration_ms INTEGER,
    -- Total time from start to complete
    
    -- ═══════════════════════════════════════════════════════════════
    -- ERROR INFO
    -- ═══════════════════════════════════════════════════════════════
    
    error_occurred BOOLEAN NOT NULL DEFAULT FALSE,
    error_step VARCHAR(50),
    error_code VARCHAR(20),
    error_message TEXT,
    error_traceback TEXT,
    -- Full traceback for debugging (not shown to users)
    
    -- ═══════════════════════════════════════════════════════════════
    -- TIMESTAMPS
    -- ═══════════════════════════════════════════════════════════════
    
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);

-- Indexes
CREATE INDEX idx_generation_jobs_document_id ON generation_jobs(document_id);
CREATE INDEX idx_generation_jobs_celery_task_id ON generation_jobs(celery_task_id) 
    WHERE celery_task_id IS NOT NULL;
CREATE INDEX idx_generation_jobs_created_at ON generation_jobs(created_at DESC);
```

### 4.2.10 API Keys Table

```sql theme={null}
-- API keys for programmatic access
-- Agencies can create multiple keys with different scopes

CREATE TABLE api_keys (
    -- Primary Key
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    
    -- ═══════════════════════════════════════════════════════════════
    -- OWNERSHIP
    -- ═══════════════════════════════════════════════════════════════
    
    agency_id UUID NOT NULL REFERENCES agencies(id) ON DELETE CASCADE,
    
    created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
    
    -- ═══════════════════════════════════════════════════════════════
    -- KEY IDENTIFICATION
    -- ═══════════════════════════════════════════════════════════════
    
    name VARCHAR(100) NOT NULL,
    -- User-friendly name: "Production Key", "Staging Key"
    
    description TEXT,
    -- Optional description
    
    -- ═══════════════════════════════════════════════════════════════
    -- KEY VALUE (Hashed)
    -- ═══════════════════════════════════════════════════════════════
    
    key_hash VARCHAR(255) NOT NULL,
    -- SHA-256 hash of the full key
    -- Original key only shown once at creation
    
    key_prefix VARCHAR(12) NOT NULL,
    -- First 8 chars for identification: "csa_live_ab"
    -- Format: csa_live_XXXXXXXX... or csa_test_XXXXXXXX...
    
    -- ═══════════════════════════════════════════════════════════════
    -- PERMISSIONS
    -- ═══════════════════════════════════════════════════════════════
    
    scopes TEXT[] NOT NULL DEFAULT '{}',
    -- Allowed operations:
    -- ['documents:create', 'documents:read', 'documents:distribute',
    --  'clients:read', 'schedule:create', 'schedule:read']
    
    -- ═══════════════════════════════════════════════════════════════
    -- RESTRICTIONS
    -- ═══════════════════════════════════════════════════════════════
    
    allowed_ips TEXT[],
    -- IP whitelist (NULL = all IPs allowed)
    -- ['192.168.1.1', '10.0.0.0/8']
    
    rate_limit_per_minute INTEGER DEFAULT 60,
    -- Requests per minute
    
    rate_limit_per_day INTEGER DEFAULT 10000,
    -- Requests per day
    
    -- ═══════════════════════════════════════════════════════════════
    -- STATUS
    -- ═══════════════════════════════════════════════════════════════
    
    is_active BOOLEAN NOT NULL DEFAULT TRUE,
    
    expires_at TIMESTAMP WITH TIME ZONE,
    -- NULL = never expires
    
    -- ═══════════════════════════════════════════════════════════════
    -- USAGE TRACKING
    -- ═══════════════════════════════════════════════════════════════
    
    last_used_at TIMESTAMP WITH TIME ZONE,
    last_used_ip VARCHAR(45),
    
    total_requests INTEGER NOT NULL DEFAULT 0,
    
    -- ═══════════════════════════════════════════════════════════════
    -- TIMESTAMPS
    -- ═══════════════════════════════════════════════════════════════
    
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    revoked_at TIMESTAMP WITH TIME ZONE,
    revoked_by_user_id UUID REFERENCES users(id)
);

-- Indexes
CREATE INDEX idx_api_keys_agency_id ON api_keys(agency_id);
CREATE INDEX idx_api_keys_key_prefix ON api_keys(key_prefix);
CREATE INDEX idx_api_keys_is_active ON api_keys(is_active) WHERE is_active = TRUE;
CREATE UNIQUE INDEX idx_api_keys_key_hash ON api_keys(key_hash);
```

### 4.2.11 Audit Logs Table

```sql theme={null}
-- Comprehensive audit trail for compliance and debugging

CREATE TABLE audit_logs (
    -- Primary Key
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    
    -- ═══════════════════════════════════════════════════════════════
    -- WHO
    -- ═══════════════════════════════════════════════════════════════
    
    user_id UUID REFERENCES users(id) ON DELETE SET NULL,
    -- NULL if action was system-initiated
    
    agency_id UUID REFERENCES agencies(id) ON DELETE SET NULL,
    -- Context agency
    
    api_key_id UUID REFERENCES api_keys(id) ON DELETE SET NULL,
    -- If action via API key
    
    -- ═══════════════════════════════════════════════════════════════
    -- WHAT
    -- ═══════════════════════════════════════════════════════════════
    
    action VARCHAR(50) NOT NULL,
    -- 'create', 'update', 'delete', 'login', 'logout', 'generate',
    -- 'distribute', 'export', 'import', 'api_key_create', etc.
    
    action_category VARCHAR(30) NOT NULL,
    -- 'auth', 'resource', 'generation', 'distribution', 'admin'
    
    resource_type VARCHAR(50) NOT NULL,
    -- 'user', 'client', 'document', 'agency', 'template', 'api_key'
    
    resource_id UUID,
    -- ID of affected resource
    
    resource_name VARCHAR(255),
    -- Human-readable identifier (for deleted resources)
    
    -- ═══════════════════════════════════════════════════════════════
    -- DETAILS
    -- ═══════════════════════════════════════════════════════════════
    
    changes JSONB,
    -- For updates: {"field": {"old": "...", "new": "..."}}
    
    metadata JSONB,
    -- Additional context
    -- {
    --   "reason": "User requested",
    --   "source": "web_ui",
    --   "duration_ms": 1500
    -- }
    
    status VARCHAR(20) DEFAULT 'success' CHECK (status IN ('success', 'failure', 'partial')),
    
    error_message TEXT,
    -- If status != 'success'
    
    -- ═══════════════════════════════════════════════════════════════
    -- REQUEST CONTEXT
    -- ═══════════════════════════════════════════════════════════════
    
    ip_address VARCHAR(45),
    user_agent TEXT,
    request_id VARCHAR(36),
    -- Correlation ID from request
    
    -- ═══════════════════════════════════════════════════════════════
    -- TIMESTAMP
    -- ═══════════════════════════════════════════════════════════════
    
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);

-- Indexes for common queries
CREATE INDEX idx_audit_logs_user_id ON audit_logs(user_id) WHERE user_id IS NOT NULL;
CREATE INDEX idx_audit_logs_agency_id ON audit_logs(agency_id) WHERE agency_id IS NOT NULL;
CREATE INDEX idx_audit_logs_resource ON audit_logs(resource_type, resource_id);
CREATE INDEX idx_audit_logs_action ON audit_logs(action);
CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at DESC);

-- Composite index for agency audit queries
CREATE INDEX idx_audit_logs_agency_time ON audit_logs(agency_id, created_at DESC)
    WHERE agency_id IS NOT NULL;

-- Consider partitioning by month for large deployments
-- This table can grow very large
```

## 4.3 Database Functions and Triggers

### 4.3.1 Auto-Update Timestamp Trigger

```sql theme={null}
-- Function to automatically update updated_at timestamp
CREATE OR REPLACE FUNCTION trigger_set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = NOW();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

-- Apply to all tables with updated_at
DO $$
DECLARE
    t text;
BEGIN
    FOR t IN 
        SELECT table_name 
        FROM information_schema.columns 
        WHERE column_name = 'updated_at' 
        AND table_schema = 'public'
    LOOP
        EXECUTE format('
            DROP TRIGGER IF EXISTS set_updated_at ON %I;
            CREATE TRIGGER set_updated_at
            BEFORE UPDATE ON %I
            FOR EACH ROW
            EXECUTE FUNCTION trigger_set_updated_at();
        ', t, t);
    END LOOP;
END;
$$;
```

### 4.3.2 Seat Limit Enforcement Trigger

```sql theme={null}
-- Prevent agencies from exceeding their seat limit
CREATE OR REPLACE FUNCTION check_agency_seat_limit()
RETURNS TRIGGER AS $$
DECLARE
    current_count INTEGER;
    max_allowed INTEGER;
    agency_name VARCHAR;
BEGIN
    -- Only check on INSERT or when activating a deactivated client
    IF TG_OP = 'UPDATE' AND (NEW.is_active = OLD.is_active) THEN
        RETURN NEW;
    END IF;
    
    IF TG_OP = 'UPDATE' AND NEW.is_active = FALSE THEN
        RETURN NEW;  -- Always allow deactivation
    END IF;
    
    -- Count current active clients for this agency
    SELECT COUNT(*) INTO current_count
    FROM clients
    WHERE agency_id = NEW.agency_id 
    AND is_active = TRUE
    AND id != COALESCE(NEW.id, '00000000-0000-0000-0000-000000000000'::uuid);
    
    -- Get max seats from agency's plan
    SELECT p.max_seats, a.name INTO max_allowed, agency_name
    FROM agencies a
    JOIN plans p ON a.plan_id = p.id
    WHERE a.id = NEW.agency_id;
    
    -- Check limit
    IF current_count >= max_allowed THEN
        RAISE EXCEPTION 'Seat limit exceeded for agency "%". Maximum % clients allowed on current plan.', 
            agency_name, max_allowed
        USING ERRCODE = 'check_violation';
    END IF;
    
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER enforce_seat_limit
BEFORE INSERT OR UPDATE ON clients
FOR EACH ROW
EXECUTE FUNCTION check_agency_seat_limit();
```

### 4.3.3 Client Statistics Update Trigger

```sql theme={null}
-- Update client statistics when documents change
CREATE OR REPLACE FUNCTION update_client_document_stats()
RETURNS TRIGGER AS $$
BEGIN
    -- Update on INSERT
    IF TG_OP = 'INSERT' THEN
        UPDATE clients SET
            total_documents_generated = total_documents_generated + 1,
            last_document_generated_at = NEW.created_at
        WHERE id = NEW.client_id;
        RETURN NEW;
    END IF;
    
    -- Update on status change to 'distributed'
    IF TG_OP = 'UPDATE' AND OLD.status != 'distributed' AND NEW.status = 'distributed' THEN
        UPDATE clients SET
            total_documents_distributed = total_documents_distributed + 1,
            last_document_distributed_at = NEW.distributed_at
        WHERE id = NEW.client_id;
    END IF;
    
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER update_client_stats
AFTER INSERT OR UPDATE ON documents
FOR EACH ROW
EXECUTE FUNCTION update_client_document_stats();
```

### 4.3.4 Generate Slug Function

```sql theme={null}
-- Generate URL-safe slug from text
CREATE OR REPLACE FUNCTION generate_slug(input_text TEXT)
RETURNS TEXT AS $$
DECLARE
    slug TEXT;
BEGIN
    -- Convert to lowercase
    slug := LOWER(input_text);
    
    -- Replace spaces and underscores with hyphens
    slug := REGEXP_REPLACE(slug, '[\s_]+', '-', 'g');
    
    -- Remove non-alphanumeric characters (except hyphens)
    slug := REGEXP_REPLACE(slug, '[^a-z0-9-]', '', 'g');
    
    -- Remove multiple consecutive hyphens
    slug := REGEXP_REPLACE(slug, '-+', '-', 'g');
    
    -- Remove leading/trailing hyphens
    slug := TRIM(BOTH '-' FROM slug);
    
    -- Truncate to reasonable length
    slug := LEFT(slug, 100);
    
    RETURN slug;
END;
$$ LANGUAGE plpgsql IMMUTABLE;
```

***

# 5. Authentication & Authorization

## 5.1 Authentication Overview

The system uses JWT (JSON Web Tokens) for stateless authentication with the following characteristics:

* **Access Tokens**: Short-lived (15 minutes), used for API requests
* **Refresh Tokens**: Long-lived (7 days), stored in HTTP-only cookies
* **API Keys**: For programmatic access, hashed in database

## 5.2 JWT Token Specifications

### 5.2.1 Access Token Structure

```python theme={null}
# Access Token Payload
{
    # Standard Claims
    "sub": "550e8400-e29b-41d4-a716-446655440000",  # User ID
    "iat": 1704481490,                               # Issued At (Unix timestamp)
    "exp": 1704482390,                               # Expiration (15 min from iat)
    "jti": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",  # JWT ID (unique)
    
    # Custom Claims
    "type": "access",
    "role": "agency_admin",                          # User role
    "agency_id": "660e8400-e29b-41d4-a716-446655440001",  # Agency context
    "client_id": null,                               # Only for client role
    "email": "user@agency.com",
    
    # Session Info
    "session_id": "sess_abc123",                     # For session tracking
}
```

### 5.2.2 Refresh Token Structure

```python theme={null}
# Refresh Token Payload
{
    "sub": "550e8400-e29b-41d4-a716-446655440000",  # User ID
    "iat": 1704481490,
    "exp": 1705086290,                              # 7 days from iat
    "jti": "b2c3d4e5-f6a7-8901-bcde-f23456789012",
    "type": "refresh",
    "session_id": "sess_abc123",
}
```

### 5.2.3 Token Configuration

```python theme={null}
# app/config.py

class AuthSettings(BaseSettings):
    # JWT Configuration
    JWT_SECRET_KEY: str  # From environment
    JWT_ALGORITHM: str = "HS256"
    
    # Token Lifetimes
    ACCESS_TOKEN_EXPIRE_MINUTES: int = 15
    REFRESH_TOKEN_EXPIRE_DAYS: int = 7
    
    # Security
    PASSWORD_MIN_LENGTH: int = 8
    PASSWORD_REQUIRE_UPPERCASE: bool = True
    PASSWORD_REQUIRE_LOWERCASE: bool = True
    PASSWORD_REQUIRE_DIGIT: bool = True
    PASSWORD_REQUIRE_SPECIAL: bool = False
    
    # Account Security
    MAX_LOGIN_ATTEMPTS: int = 5
    LOCKOUT_DURATION_MINUTES: int = 15
    
    # Session
    SESSION_COOKIE_NAME: str = "refresh_token"
    SESSION_COOKIE_SECURE: bool = True  # HTTPS only
    SESSION_COOKIE_HTTPONLY: bool = True
    SESSION_COOKIE_SAMESITE: str = "lax"
```

## 5.3 Authentication Flow Diagrams

### 5.3.1 Login Flow

```
┌─────────────────────────────────────────────────────────────────────────────────┐
│                                 LOGIN FLOW                                       │
└─────────────────────────────────────────────────────────────────────────────────┘

Client                          API Server                         Database
  │                                 │                                  │
  │  POST /api/v1/auth/login       │                                  │
  │  {email, password}             │                                  │
  │────────────────────────────────►                                  │
  │                                 │                                  │
  │                                 │  SELECT user WHERE email = ?     │
  │                                 │─────────────────────────────────►│
  │                                 │                                  │
  │                                 │  User record                     │
  │                                 │◄─────────────────────────────────│
  │                                 │                                  │
  │                                 │  ┌─────────────────────────────┐ │
  │                                 │  │ 1. Check account is active  │ │
  │                                 │  │ 2. Check not locked         │ │
  │                                 │  │ 3. Verify password (bcrypt) │ │
  │                                 │  │ 4. Check agency is active   │ │
  │                                 │  │ 5. Check subscription valid │ │
  │                                 │  └─────────────────────────────┘ │
  │                                 │                                  │
  │                                 │  ┌─────────────────────────────┐ │
  │                                 │  │ If password wrong:          │ │
  │                                 │  │ - Increment failed_attempts │ │
  │                                 │  │ - Lock if >= 5 attempts     │ │
  │                                 │  └─────────────────────────────┘ │
  │                                 │                                  │
  │                                 │  ┌─────────────────────────────┐ │
  │                                 │  │ If success:                 │ │
  │                                 │  │ - Reset failed_attempts     │ │
  │                                 │  │ - Update last_login_at      │ │
  │                                 │  │ - Generate access token     │ │
  │                                 │  │ - Generate refresh token    │ │
  │                                 │  │ - Create audit log          │ │
  │                                 │  └─────────────────────────────┘ │
  │                                 │                                  │
  │  200 OK                         │                                  │
  │  {access_token, user}          │                                  │
  │  Set-Cookie: refresh_token     │                                  │
  │◄────────────────────────────────│                                  │
  │                                 │                                  │
```

### 5.3.2 Token Refresh Flow

```
┌─────────────────────────────────────────────────────────────────────────────────┐
│                              TOKEN REFRESH FLOW                                  │
└─────────────────────────────────────────────────────────────────────────────────┘

Client                          API Server                         Redis
  │                                 │                                  │
  │  POST /api/v1/auth/refresh      │                                  │
  │  Cookie: refresh_token=xyz      │                                  │
  │────────────────────────────────►│                                  │
  │                                 │                                  │
  │                                 │  ┌─────────────────────────────┐ │
  │                                 │  │ 1. Extract refresh token    │ │
  │                                 │  │ 2. Verify JWT signature     │ │
  │                                 │  │ 3. Check not expired        │ │
  │                                 │  └─────────────────────────────┘ │
  │                                 │                                  │
  │                                 │  CHECK blacklist:jti_xyz        │
  │                                 │─────────────────────────────────►│
  │                                 │                                  │
  │                                 │  (not found = not blacklisted)  │
  │                                 │◄─────────────────────────────────│
  │                                 │                                  │
  │                                 │  ┌─────────────────────────────┐ │
  │                                 │  │ 1. Load user from DB        │ │
  │                                 │  │ 2. Verify still active      │ │
  │                                 │  │ 3. Generate new access token│ │
  │                                 │  │ 4. (Optional) Rotate refresh│ │
  │                                 │  └─────────────────────────────┘ │
  │                                 │                                  │
  │  200 OK                         │                                  │
  │  {access_token}                │                                  │
  │◄────────────────────────────────│                                  │
  │                                 │                                  │
```

### 5.3.3 API Key Authentication Flow

```
┌─────────────────────────────────────────────────────────────────────────────────┐
│                           API KEY AUTHENTICATION                                 │
└─────────────────────────────────────────────────────────────────────────────────┘

Client                          API Server                         Database
  │                                 │                                  │
  │  POST /api/v1/documents/generate│                                  │
  │  X-API-Key: csa_live_abc123... │                                  │
  │────────────────────────────────►│                                  │
  │                                 │                                  │
  │                                 │  ┌─────────────────────────────┐ │
  │                                 │  │ 1. Extract API key header   │ │
  │                                 │  │ 2. Validate format          │ │
  │                                 │  │ 3. Hash the key (SHA-256)   │ │
  │                                 │  └─────────────────────────────┘ │
  │                                 │                                  │
  │                                 │  SELECT * FROM api_keys         │
  │                                 │  WHERE key_hash = ?              │
  │                                 │─────────────────────────────────►│
  │                                 │                                  │
  │                                 │  API key record                 │
  │                                 │◄─────────────────────────────────│
  │                                 │                                  │
  │                                 │  ┌─────────────────────────────┐ │
  │                                 │  │ 1. Check is_active          │ │
  │                                 │  │ 2. Check not expired        │ │
  │                                 │  │ 3. Check IP allowlist       │ │
  │                                 │  │ 4. Check rate limits        │ │
  │                                 │  │ 5. Check required scope     │ │
  │                                 │  │ 6. Load agency              │ │
  │                                 │  └─────────────────────────────┘ │
  │                                 │                                  │
  │                                 │  UPDATE api_keys SET            │
  │                                 │  last_used_at = NOW(),           │
  │                                 │  total_requests = total + 1      │
  │                                 │─────────────────────────────────►│
  │                                 │                                  │
  │  (continues to handler)        │                                  │
  │                                 │                                  │
```

## 5.4 Authorization (RBAC)

### 5.4.1 Permission Definitions

```python theme={null}
# app/utils/permissions.py

from enum import Enum
from typing import List, Set

class Permission(str, Enum):
    """All available permissions in the system"""
    
    # ═══════════════════════════════════════════════════════════════
    # SUPER ADMIN PERMISSIONS
    # ═══════════════════════════════════════════════════════════════
    ADMIN_FULL = "admin:*"
    ADMIN_AGENCIES_MANAGE = "admin:agencies:manage"
    ADMIN_TEMPLATES_MANAGE = "admin:templates:manage"
    ADMIN_PLANS_MANAGE = "admin:plans:manage"
    ADMIN_SYSTEM_VIEW = "admin:system:view"
    
    # ═══════════════════════════════════════════════════════════════
    # AGENCY MANAGEMENT
    # ═══════════════════════════════════════════════════════════════
    AGENCY_READ = "agency:read"
    AGENCY_UPDATE = "agency:update"
    AGENCY_BRANDING_UPDATE = "agency:branding:update"
    AGENCY_API_KEYS_MANAGE = "agency:api_keys:manage"
    AGENCY_SETTINGS_UPDATE = "agency:settings:update"
    
    # ═══════════════════════════════════════════════════════════════
    # TEAM MANAGEMENT
    # ═══════════════════════════════════════════════════════════════
    TEAM_LIST = "team:list"
    TEAM_CREATE = "team:create"
    TEAM_UPDATE = "team:update"
    TEAM_DELETE = "team:delete"
    
    # ═══════════════════════════════════════════════════════════════
    # CLIENT MANAGEMENT
    # ═══════════════════════════════════════════════════════════════
    CLIENTS_LIST = "clients:list"
    CLIENTS_CREATE = "clients:create"
    CLIENTS_READ = "clients:read"
    CLIENTS_UPDATE = "clients:update"
    CLIENTS_DELETE = "clients:delete"
    CLIENTS_READ_OWN = "clients:read:own"  # Client user only
    
    # ═══════════════════════════════════════════════════════════════
    # DOCUMENT MANAGEMENT
    # ═══════════════════════════════════════════════════════════════
    DOCUMENTS_LIST = "documents:list"
    DOCUMENTS_CREATE = "documents:create"
    DOCUMENTS_READ = "documents:read"
    DOCUMENTS_UPDATE = "documents:update"
    DOCUMENTS_DELETE = "documents:delete"
    DOCUMENTS_DISTRIBUTE = "documents:distribute"
    DOCUMENTS_EXPORT = "documents:export"
    DOCUMENTS_READ_OWN = "documents:read:own"  # Client user only
    DOCUMENTS_CREATE_OWN = "documents:create:own"  # Client user only
    
    # ═══════════════════════════════════════════════════════════════
    # SCHEDULE MANAGEMENT
    # ═══════════════════════════════════════════════════════════════
    SCHEDULE_LIST = "schedule:list"
    SCHEDULE_CREATE = "schedule:create"
    SCHEDULE_UPDATE = "schedule:update"
    SCHEDULE_DELETE = "schedule:delete"
    SCHEDULE_IMPORT = "schedule:import"  # CSV import
    
    # ═══════════════════════════════════════════════════════════════
    # TEMPLATE ACCESS
    # ═══════════════════════════════════════════════════════════════
    TEMPLATES_LIST = "templates:list"
    TEMPLATES_READ = "templates:read"


# Role to permissions mapping
ROLE_PERMISSIONS: dict[str, Set[Permission]] = {
    
    "super_admin": {
        Permission.ADMIN_FULL,
        # Super admin has all permissions implicitly
    },
    
    "agency_admin": {
        # Agency
        Permission.AGENCY_READ,
        Permission.AGENCY_UPDATE,
        Permission.AGENCY_BRANDING_UPDATE,
        Permission.AGENCY_API_KEYS_MANAGE,
        Permission.AGENCY_SETTINGS_UPDATE,
        
        # Team
        Permission.TEAM_LIST,
        Permission.TEAM_CREATE,
        Permission.TEAM_UPDATE,
        Permission.TEAM_DELETE,
        
        # Clients
        Permission.CLIENTS_LIST,
        Permission.CLIENTS_CREATE,
        Permission.CLIENTS_READ,
        Permission.CLIENTS_UPDATE,
        Permission.CLIENTS_DELETE,
        
        # Documents
        Permission.DOCUMENTS_LIST,
        Permission.DOCUMENTS_CREATE,
        Permission.DOCUMENTS_READ,
        Permission.DOCUMENTS_UPDATE,
        Permission.DOCUMENTS_DELETE,
        Permission.DOCUMENTS_DISTRIBUTE,
        Permission.DOCUMENTS_EXPORT,
        
        # Schedule
        Permission.SCHEDULE_LIST,
        Permission.SCHEDULE_CREATE,
        Permission.SCHEDULE_UPDATE,
        Permission.SCHEDULE_DELETE,
        Permission.SCHEDULE_IMPORT,
        
        # Templates
        Permission.TEMPLATES_LIST,
        Permission.TEMPLATES_READ,
    },
    
    "agency_member": {
        # Agency (read only)
        Permission.AGENCY_READ,
        
        # No team management
        Permission.TEAM_LIST,
        
        # Clients (read only)
        Permission.CLIENTS_LIST,
        Permission.CLIENTS_READ,
        
        # Documents (create and distribute, no delete)
        Permission.DOCUMENTS_LIST,
        Permission.DOCUMENTS_CREATE,
        Permission.DOCUMENTS_READ,
        Permission.DOCUMENTS_DISTRIBUTE,
        Permission.DOCUMENTS_EXPORT,
        
        # Schedule (read only)
        Permission.SCHEDULE_LIST,
        
        # Templates
        Permission.TEMPLATES_LIST,
        Permission.TEMPLATES_READ,
    },
    
    "client": {
        # Own client only
        Permission.CLIENTS_READ_OWN,
        
        # Own documents only
        Permission.DOCUMENTS_READ_OWN,
        Permission.DOCUMENTS_CREATE_OWN,
    },
}
```

### 5.4.2 Permission Checking Implementation

```python theme={null}
# app/api/deps.py

from functools import wraps
from typing import Callable, List, Optional
from fastapi import Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession

from app.models.user import User
from app.utils.permissions import Permission, ROLE_PERMISSIONS


def has_permission(user: User, permission: Permission) -> bool:
    """Check if a user has a specific permission"""
    
    # Super admin has all permissions
    if user.role == "super_admin":
        return True
    
    # Get permissions for user's role
    role_perms = ROLE_PERMISSIONS.get(user.role, set())
    
    # Check for wildcard permission
    if Permission.ADMIN_FULL in role_perms:
        return True
    
    # Check for specific permission
    return permission in role_perms


def require_permissions(*permissions: Permission):
    """
    Dependency that checks if current user has required permissions.
    
    Usage:
        @router.post("/clients")
        async def create_client(
            ...,
            _: None = Depends(require_permissions(Permission.CLIENTS_CREATE))
        ):
    """
    async def permission_checker(
        current_user: User = Depends(get_current_active_user)
    ) -> User:
        for permission in permissions:
            if not has_permission(current_user, permission):
                raise HTTPException(
                    status_code=status.HTTP_403_FORBIDDEN,
                    detail={
                        "error": "permission_denied",
                        "message": f"Missing required permission: {permission.value}",
                        "required_permission": permission.value
                    }
                )
        return current_user
    
    return permission_checker


def require_any_permission(*permissions: Permission):
    """Check if user has at least one of the specified permissions"""
    async def permission_checker(
        current_user: User = Depends(get_current_active_user)
    ) -> User:
        for permission in permissions:
            if has_permission(current_user, permission):
                return current_user
        
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail={
                "error": "permission_denied",
                "message": "Missing required permissions",
                "required_any": [p.value for p in permissions]
            }
        )
    
    return permission_checker
```

### 5.4.3 Resource-Level Access Control

```python theme={null}
# app/services/auth_service.py

from typing import Optional
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select

from app.models import User, Client, Document


async def check_resource_access(
    db: AsyncSession,
    user: User,
    resource_type: str,
    resource_id: UUID,
    action: str = "read"
) -> bool:
    """
    Check if a user can access a specific resource.
    
    Args:
        db: Database session
        user: The user attempting access
        resource_type: Type of resource ('client', 'document', etc.)
        resource_id: ID of the resource
        action: The action being performed
        
    Returns:
        True if access is allowed, False otherwise
    """
    
    # Super admin can access everything
    if user.role == "super_admin":
        return True
    
    # User must belong to an agency
    if not user.agency_id:
        return False
    
    if resource_type == "client":
        return await _check_client_access(db, user, resource_id, action)
    
    elif resource_type == "document":
        return await _check_document_access(db, user, resource_id, action)
    
    elif resource_type == "scheduled_content":
        return await _check_schedule_access(db, user, resource_id, action)
    
    return False


async def _check_client_access(
    db: AsyncSession,
    user: User,
    client_id: UUID,
    action: str
) -> bool:
    """Check access to a client resource"""
    
    # Load the client
    result = await db.execute(
        select(Client).where(Client.id == client_id)
    )
    client = result.scalar_one_or_none()
    
    if not client:
        return False
    
    # Client must belong to user's agency
    if client.agency_id != user.agency_id:
        return False
    
    # For client role, can only access their own client
    if user.role == "client":
        return client.id == user.client_id
    
    # Agency admin and member can access all clients in their agency
    return True


async def _check_document_access(
    db: AsyncSession,
    user: User,
    document_id: UUID,
    action: str
) -> bool:
    """Check access to a document resource"""
    
    # Load the document with client
    result = await db.execute(
        select(Document)
        .join(Client)
        .where(Document.id == document_id)
    )
    document = result.scalar_one_or_none()
    
    if not document:
        return False
    
    # Document's client must belong to user's agency
    client = document.client
    if client.agency_id != user.agency_id:
        return False
    
    # For client role, can only access documents for their client
    if user.role == "client":
        return client.id == user.client_id
    
    # Check action-specific permissions for agency_member
    if user.role == "agency_member":
        if action == "delete":
            return False  # Members cannot delete
        if action == "update" and document.status == "distributed":
            return False  # Cannot edit distributed documents
    
    return True
```

***

# 6. API Design

## 6.1 API Overview

### Base URL Structure

```
Production:  https://api.contentstrategist.com/api/v1
Agency:      https://{agency-slug}.contentstrategist.com/api/v1
Custom:      https://{custom-domain}/api/v1
```

### Common Headers

```
Authorization: Bearer {access_token}
X-API-Key: csa_live_{key}  (alternative to Bearer)
Content-Type: application/json
Accept: application/json
X-Request-ID: {uuid}  (optional, for tracing)
```

### Response Format

```json theme={null}
// Success Response
{
    "success": true,
    "data": { ... },
    "meta": {
        "request_id": "req_abc123",
        "timestamp": "2026-01-02T10:30:00Z"
    }
}

// Error Response
{
    "success": false,
    "error": {
        "code": "VALIDATION_ERROR",
        "message": "Invalid request parameters",
        "details": [
            {
                "field": "topic",
                "message": "Topic must be at least 3 characters"
            }
        ]
    },
    "meta": {
        "request_id": "req_abc123",
        "timestamp": "2026-01-02T10:30:00Z"
    }
}

// Paginated Response
{
    "success": true,
    "data": [ ... ],
    "pagination": {
        "page": 1,
        "per_page": 20,
        "total_items": 157,
        "total_pages": 8,
        "has_next": true,
        "has_prev": false
    },
    "meta": { ... }
}
```

## 6.2 Authentication Endpoints

### POST /api/v1/auth/login

Login with email and password.

**Request:**

```json theme={null}
{
    "email": "user@agency.com",
    "password": "securepassword123"
}
```

**Response (200 OK):**

```json theme={null}
{
    "success": true,
    "data": {
        "access_token": "eyJhbGciOiJIUzI1NiIs...",
        "token_type": "bearer",
        "expires_in": 900,
        "user": {
            "id": "550e8400-e29b-41d4-a716-446655440000",
            "email": "user@agency.com",
            "name": "John Doe",
            "role": "agency_admin",
            "agency": {
                "id": "660e8400-e29b-41d4-a716-446655440001",
                "name": "Acme Agency",
                "slug": "acme"
            }
        }
    }
}
```

**Set-Cookie Header:**

```
Set-Cookie: refresh_token=eyJ...; HttpOnly; Secure; SameSite=Lax; Path=/api/v1/auth; Max-Age=604800
```

**Error Responses:**

* `401 Unauthorized`: Invalid credentials
* `403 Forbidden`: Account locked or inactive
* `422 Unprocessable Entity`: Invalid request format

***

### POST /api/v1/auth/refresh

Refresh access token using refresh token cookie.

**Request:** (no body, uses cookie)

**Response (200 OK):**

```json theme={null}
{
    "success": true,
    "data": {
        "access_token": "eyJhbGciOiJIUzI1NiIs...",
        "token_type": "bearer",
        "expires_in": 900
    }
}
```

***

### POST /api/v1/auth/logout

Logout and invalidate tokens.

**Response (200 OK):**

```json theme={null}
{
    "success": true,
    "data": {
        "message": "Successfully logged out"
    }
}
```

**Set-Cookie Header:**

```
Set-Cookie: refresh_token=; HttpOnly; Secure; SameSite=Lax; Path=/api/v1/auth; Max-Age=0
```

***

### GET /api/v1/auth/me

Get current user information.

**Response (200 OK):**

```json theme={null}
{
    "success": true,
    "data": {
        "id": "550e8400-e29b-41d4-a716-446655440000",
        "email": "user@agency.com",
        "first_name": "John",
        "last_name": "Doe",
        "display_name": "John Doe",
        "role": "agency_admin",
        "avatar_url": null,
        "agency": {
            "id": "660e8400-e29b-41d4-a716-446655440001",
            "name": "Acme Agency",
            "slug": "acme",
            "plan": {
                "name": "enterprise_annual",
                "display_name": "Enterprise (Annual)"
            }
        },
        "permissions": [
            "agency:read",
            "agency:update",
            "clients:create",
            ...
        ],
        "preferences": {
            "timezone": "America/New_York",
            "locale": "en-US"
        },
        "last_login_at": "2026-01-02T09:00:00Z"
    }
}
```

***

### POST /api/v1/auth/password/forgot

Request password reset email.

**Request:**

```json theme={null}
{
    "email": "user@agency.com"
}
```

**Response (200 OK):**

```json theme={null}
{
    "success": true,
    "data": {
        "message": "If an account exists, a reset email has been sent"
    }
}
```

***

### POST /api/v1/auth/password/reset

Reset password with token.

**Request:**

```json theme={null}
{
    "token": "reset_token_from_email",
    "password": "newSecurePassword123",
    "password_confirmation": "newSecurePassword123"
}
```

**Response (200 OK):**

```json theme={null}
{
    "success": true,
    "data": {
        "message": "Password successfully reset"
    }
}
```

## 6.3 Client Management Endpoints

### GET /api/v1/clients

List all clients for the agency.

**Query Parameters:**

| Parameter  | Type    | Default     | Description                            |
| ---------- | ------- | ----------- | -------------------------------------- |
| page       | integer | 1           | Page number                            |
| per\_page  | integer | 20          | Items per page (max 100)               |
| search     | string  |             | Search in company\_name, contact\_name |
| industry   | string  |             | Filter by industry                     |
| is\_active | boolean | true        | Filter by active status                |
| sort       | string  | created\_at | Sort field                             |
| order      | string  | desc        | Sort order (asc/desc)                  |

**Response (200 OK):**

```json theme={null}
{
    "success": true,
    "data": [
        {
            "id": "770e8400-e29b-41d4-a716-446655440002",
            "company_name": "Acme Corporation",
            "company_slug": "acme-corp",
            "contact_name": "Jane Smith",
            "contact_email": "jane@acmecorp.com",
            "contact_phone": "+1 555-123-4567",
            "website_url": "https://acmecorp.com",
            "industry": "Technology",
            "logo_horizontal_url": "https://storage.../logo.png",
            "is_active": true,
            "total_documents_generated": 45,
            "total_documents_distributed": 38,
            "last_document_generated_at": "2026-01-01T15:30:00Z",
            "social_connections": {
                "linkedin": true,
                "facebook": true,
                "twitter": false,
                "google_business": false
            },
            "created_at": "2025-06-15T10:00:00Z"
        }
    ],
    "pagination": {
        "page": 1,
        "per_page": 20,
        "total_items": 45,
        "total_pages": 3
    }
}
```

***

### POST /api/v1/clients

Create a new client.

**Request:**

```json theme={null}
{
    "company_name": "NewCo Industries",
    "contact_name": "Bob Johnson",
    "contact_email": "bob@newco.com",
    "contact_phone": "+1 555-987-6543",
    "website_url": "https://newco.com",
    "industry": "Manufacturing",
    "company_description": "Leading manufacturer of industrial equipment",
    
    "default_tone": "professional",
    "related_services": ["Equipment Consulting", "Maintenance Plans"],
    "target_keywords": ["industrial equipment", "manufacturing solutions"],
    "additional_context": "Focus on B2B enterprise customers"
}
```

**Response (201 Created):**

```json theme={null}
{
    "success": true,
    "data": {
        "id": "880e8400-e29b-41d4-a716-446655440003",
        "company_name": "NewCo Industries",
        "company_slug": "newco-industries",
        ...
    }
}
```

**Error Responses:**

* `400 Bad Request`: Seat limit exceeded
* `422 Unprocessable Entity`: Validation errors

***

### GET /api/v1/clients/\{client\_id}

Get client details.

**Response (200 OK):**

```json theme={null}
{
    "success": true,
    "data": {
        "id": "770e8400-e29b-41d4-a716-446655440002",
        "company_name": "Acme Corporation",
        "company_slug": "acme-corp",
        "contact_name": "Jane Smith",
        "contact_email": "jane@acmecorp.com",
        "contact_phone": "+1 555-123-4567",
        "contact_title": "VP of Marketing",
        "website_url": "https://acmecorp.com",
        "industry": "Technology",
        "company_size": "201-500",
        "company_description": "Enterprise software company...",
        
        "branding": {
            "logo_horizontal_url": "https://storage.../logo.png",
            "logo_vertical_url": null,
            "logo_round_url": null,
            "color_accent_1": "#1a4a6e",
            "color_accent_2": "#b8860b",
            "color_accent_3": "#2980b9",
            "footer_text": "© 2026 Acme Corporation"
        },
        
        "content_defaults": {
            "default_tone": "professional",
            "related_services": ["Cloud Migration", "Data Analytics"],
            "target_keywords": ["enterprise software", "digital transformation"],
            "additional_context": "Target Fortune 500 companies",
            "brand_voice_notes": "Formal but approachable"
        },
        
        "social_connections": {
            "linkedin": {
                "connected": true,
                "page_name": "Acme Corporation",
                "connected_at": "2025-11-01T10:00:00Z"
            },
            "facebook": {
                "connected": true,
                "page_name": "Acme Corp",
                "connected_at": "2025-11-01T10:05:00Z"
            },
            "twitter": {
                "connected": false
            },
            "google_business": {
                "connected": false
            }
        },
        
        "statistics": {
            "total_documents_generated": 45,
            "total_documents_distributed": 38,
            "last_document_generated_at": "2026-01-01T15:30:00Z",
            "last_document_distributed_at": "2026-01-01T16:00:00Z"
        },
        
        "is_active": true,
        "created_at": "2025-06-15T10:00:00Z",
        "updated_at": "2026-01-01T16:00:00Z"
    }
}
```

***

### PUT /api/v1/clients/\{client\_id}

Update client details.

**Request:**

```json theme={null}
{
    "company_name": "Acme Corporation",
    "contact_name": "Jane Smith-Wilson",
    "industry": "Technology & Software",
    ...
}
```

**Response (200 OK):**

```json theme={null}
{
    "success": true,
    "data": {
        "id": "770e8400-e29b-41d4-a716-446655440002",
        ...
    }
}
```

***

### PUT /api/v1/clients/\{client\_id}/branding

Update client branding (logos, colors).

**Request:**

```json theme={null}
{
    "color_accent_1": "#2563eb",
    "color_accent_2": "#d97706",
    "color_accent_3": "#059669",
    "footer_text": "© 2026 Acme Corporation. All rights reserved."
}
```

**Response (200 OK):** Updated client object

***

### POST /api/v1/clients/\{client\_id}/branding/logo

Upload client logo.

**Request:** `multipart/form-data`

| Field | Type   | Description                            |
| ----- | ------ | -------------------------------------- |
| file  | file   | Image file (JPG, PNG, WebP)            |
| type  | string | Logo type: horizontal, vertical, round |

**Response (200 OK):**

```json theme={null}
{
    "success": true,
    "data": {
        "logo_url": "https://storage.../client-id/logo-horizontal.png",
        "type": "horizontal",
        "width": 400,
        "height": 100
    }
}
```

***

### DELETE /api/v1/clients/\{client\_id}

Deactivate a client (soft delete).

**Response (200 OK):**

```json theme={null}
{
    "success": true,
    "data": {
        "message": "Client deactivated successfully"
    }
}
```

## 6.4 Document Generation Endpoints

### POST /api/v1/clients/\{client\_id}/documents/generate

Start content generation for a client.

**Request:**

```json theme={null}
{
    "topic": "AI Implementation Strategies for Mid-Market Companies",
    
    "tone": "authoritative",
    "template_code": "EXEC_01",
    
    "keywords": ["AI adoption", "digital transformation", "ROI"],
    "related_services": ["AI Consulting", "Implementation Services"],
    
    "custom_direction": "Focus on practical steps and include case studies. Emphasize ROI metrics.",
    "additional_context": "Target audience is C-level executives at companies with 500-5000 employees.",
    
    "cover_image_search": "artificial intelligence business strategy",
    
    "auto_distribute": false,
    "distribution_channels": {
        "linkedin": true,
        "facebook": false,
        "twitter": false,
        "google_business": false
    }
}
```

**Response (202 Accepted):**

```json theme={null}
{
    "success": true,
    "data": {
        "document_id": "990e8400-e29b-41d4-a716-446655440004",
        "job_id": "aa0e8400-e29b-41d4-a716-446655440005",
        "status": "pending",
        "estimated_duration_seconds": 120,
        "websocket_url": "wss://acme.contentstrategist.com/api/v1/ws/generation/aa0e8400-e29b-41d4-a716-446655440005",
        "polling_url": "/api/v1/documents/990e8400-e29b-41d4-a716-446655440004/status"
    }
}
```

***

### GET /api/v1/documents/\{document\_id}/status

Get generation status (for polling).

**Response (200 OK) - In Progress:**

```json theme={null}
{
    "success": true,
    "data": {
        "document_id": "990e8400-e29b-41d4-a716-446655440004",
        "status": "generating",
        "progress": {
            "percent": 45,
            "current_step": "web_research",
            "current_step_label": "Researching Topic",
            "current_step_detail": "Reading 47 of ~120 sources...",
            "steps_completed": ["topic_analysis", "keyword_research"],
            "steps_remaining": ["industry_analysis", "outline_creation", "content_writing", "statistics_integration", "chart_generation", "template_application", "pdf_rendering", "quality_review"]
        },
        "started_at": "2026-01-02T10:30:00Z",
        "estimated_completion_at": "2026-01-02T10:32:30Z"
    }
}
```

**Response (200 OK) - Complete:**

```json theme={null}
{
    "success": true,
    "data": {
        "document_id": "990e8400-e29b-41d4-a716-446655440004",
        "status": "ready",
        "progress": {
            "percent": 100,
            "current_step": "complete",
            "steps_completed": ["topic_analysis", "keyword_research", "web_research", "industry_analysis", "outline_creation", "content_writing", "statistics_integration", "chart_generation", "template_application", "pdf_rendering", "quality_review"]
        },
        "result": {
            "title": "AI Implementation: A Strategic Guide for Enterprise Leaders",
            "pdf_url": "https://storage.contentstrategist.com/acme/documents/990e8400.pdf",
            "page_count": 8,
            "word_count": 2847,
            "research_sources_count": 47
        },
        "started_at": "2026-01-02T10:30:00Z",
        "completed_at": "2026-01-02T10:32:28Z",
        "duration_seconds": 148
    }
}
```

***

### GET /api/v1/documents/\{document\_id}

Get full document details.

**Response (200 OK):**

```json theme={null}
{
    "success": true,
    "data": {
        "id": "990e8400-e29b-41d4-a716-446655440004",
        "client_id": "770e8400-e29b-41d4-a716-446655440002",
        "template_id": "bb0e8400-e29b-41d4-a716-446655440006",
        
        "title": "AI Implementation: A Strategic Guide for Enterprise Leaders",
        "subtitle": "How Forward-Thinking Companies Are Winning with Artificial Intelligence",
        "topic": "AI Implementation Strategies for Mid-Market Companies",
        
        "content_summary": {
            "sections": [
                "Start with High-Impact, Low-Risk Use Cases",
                "Invest in Data Infrastructure Before Algorithms",
                "Build vs. Buy: Making the Right Choice",
                "Measure What Matters: AI ROI Frameworks",
                "The Bottom Line"
            ],
            "statistics_count": 8,
            "charts_count": 3,
            "quotes_count": 2
        },
        
        "files": {
            "pdf_url": "https://storage.../documents/990e8400.pdf",
            "pdf_file_size_bytes": 1248576,
            "pdf_page_count": 8,
            "cover_image_url": "https://storage.../covers/990e8400.jpg"
        },
        
        "generation_input": {
            "tone": "authoritative",
            "industry": "Technology",
            "keywords": ["AI adoption", "digital transformation", "ROI"],
            "related_services": ["AI Consulting", "Implementation Services"],
            "custom_direction": "Focus on practical steps..."
        },
        
        "research_summary": {
            "source_count": 47,
            "top_sources": [
                {"domain": "mckinsey.com", "count": 5},
                {"domain": "hbr.org", "count": 4},
                {"domain": "gartner.com", "count": 3}
            ]
        },
        
        "status": "ready",
        "created_by": {
            "id": "550e8400-e29b-41d4-a716-446655440000",
            "name": "John Doe"
        },
        
        "distribution": {
            "distributed": false,
            "channels_configured": {
                "linkedin": true,
                "facebook": false,
                "twitter": false,
                "google_business": false
            }
        },
        
        "api_usage": {
            "tokens_input": 15420,
            "tokens_output": 8750,
            "cost_cents": 42
        },
        
        "created_at": "2026-01-02T10:30:00Z",
        "generation_completed_at": "2026-01-02T10:32:28Z",
        "expires_at": "2029-01-02T10:30:00Z"
    }
}
```

***

### GET /api/v1/documents/\{document\_id}/content

Get full structured content (for editing/display).

**Response (200 OK):**

```json theme={null}
{
    "success": true,
    "data": {
        "version": 1,
        "title": "AI Implementation: A Strategic Guide for Enterprise Leaders",
        "subtitle": "How Forward-Thinking Companies Are Winning with Artificial Intelligence",
        "summary": "In an era where artificial intelligence is reshaping industries...",
        
        "sections": [
            {
                "number": 1,
                "label": "Strategy 1:",
                "title": "Start with High-Impact, Low-Risk Use Cases",
                "lead": "The most successful AI implementations begin not with the flashiest technology, but with carefully selected use cases that balance potential impact against implementation risk.",
                "paragraphs": [
                    "When McKinsey surveyed 1,000 companies about their AI initiatives...",
                    "Consider the approach taken by Midwest Manufacturing..."
                ],
                "subheadings": [
                    {
                        "title": "Identifying Quick Wins",
                        "content": "The best starting points share common characteristics..."
                    }
                ],
                "callout": {
                    "type": "tip",
                    "content": "Pro tip: Start with customer service automation..."
                }
            }
        ],
        
        "statistics": [
            {
                "id": "stat_1",
                "value": "73%",
                "label": "of companies report exceeding ROI expectations within 18 months",
                "source": "McKinsey Global AI Survey 2025",
                "source_url": "https://mckinsey.com/...",
                "type": "percentage"
            }
        ],
        
        "charts": [
            {
                "id": "chart_1",
                "type": "bar",
                "title": "AI Investment ROI by Industry Sector",
                "data": {
                    "labels": ["Healthcare", "Finance", "Manufacturing", "Retail", "Technology"],
                    "datasets": [
                        {
                            "label": "Average ROI %",
                            "values": [320, 280, 245, 190, 175]
                        }
                    ]
                },
                "source": "Deloitte AI Investment Report 2025"
            }
        ],
        
        "quotes": [
            {
                "id": "quote_1",
                "text": "AI is not just a technology investment—it's a fundamental reimagining of how work gets done.",
                "attribution": "Satya Nadella",
                "title": "CEO, Microsoft",
                "source": "World Economic Forum 2025"
            }
        ],
        
        "conclusion": {
            "title": "The Bottom Line",
            "lead": "AI implementation success is not about having the most advanced technology...",
            "content": "The companies winning with AI share a common approach...",
            "cta": "Ready to develop your AI strategy? Contact us to discuss how these principles apply to your organization."
        }
    }
}
```

***

### GET /api/v1/documents/\{document\_id}/pdf

Download the PDF file.

**Response:** Binary PDF file with appropriate headers

```
Content-Type: application/pdf
Content-Disposition: attachment; filename="ai-implementation-guide-2026-01-02.pdf"
Content-Length: 1248576
```

***

### POST /api/v1/documents/\{document\_id}/distribute

Distribute document to social channels.

**Request:**

```json theme={null}
{
    "channels": {
        "linkedin": true,
        "facebook": true,
        "twitter": false,
        "google_business": false
    },
    "custom_message": "Excited to share our latest insights on AI implementation strategies. Key takeaway: Start small, measure everything, and scale what works. #AI #DigitalTransformation"
}
```

**Response (202 Accepted):**

```json theme={null}
{
    "success": true,
    "data": {
        "distribution_id": "dist_cc0e8400",
        "status": "processing",
        "channels": {
            "linkedin": {"status": "queued"},
            "facebook": {"status": "queued"}
        },
        "polling_url": "/api/v1/documents/990e8400/distribution/dist_cc0e8400"
    }
}
```

***

### GET /api/v1/clients/\{client\_id}/documents

List documents for a client.

**Query Parameters:**

| Parameter  | Type    | Default     | Description                                                        |
| ---------- | ------- | ----------- | ------------------------------------------------------------------ |
| page       | integer | 1           | Page number                                                        |
| per\_page  | integer | 20          | Items per page                                                     |
| status     | string  |             | Filter by status (pending, generating, ready, distributed, failed) |
| search     | string  |             | Search in title, topic                                             |
| date\_from | string  |             | Filter by created\_at >= date                                      |
| date\_to   | string  |             | Filter by created\_at \<= date                                     |
| sort       | string  | created\_at | Sort field                                                         |
| order      | string  | desc        | Sort order                                                         |

**Response (200 OK):**

```json theme={null}
{
    "success": true,
    "data": [
        {
            "id": "990e8400-e29b-41d4-a716-446655440004",
            "title": "AI Implementation: A Strategic Guide",
            "topic": "AI Implementation Strategies",
            "status": "distributed",
            "template": {
                "code": "EXEC_01",
                "name": "Executive Professional"
            },
            "pdf_url": "https://storage.../990e8400.pdf",
            "cover_image_url": "https://storage.../990e8400-cover.jpg",
            "page_count": 8,
            "distribution": {
                "distributed_at": "2026-01-02T11:00:00Z",
                "channels": ["linkedin", "facebook"]
            },
            "created_at": "2026-01-02T10:30:00Z",
            "created_by": {
                "id": "550e8400",
                "name": "John Doe"
            }
        }
    ],
    "pagination": { ... }
}
```

## 6.5 Schedule Management Endpoints

### GET /api/v1/clients/\{client\_id}/schedule

List scheduled content for a client.

**Query Parameters:**

| Parameter  | Type    | Default       | Description                                              |
| ---------- | ------- | ------------- | -------------------------------------------------------- |
| page       | integer | 1             | Page number                                              |
| per\_page  | integer | 50            | Items per page                                           |
| status     | string  |               | Filter: pending, processing, completed, failed, canceled |
| date\_from | date    |               | Filter by scheduled\_date >=                             |
| date\_to   | date    |               | Filter by scheduled\_date \<=                            |
| sort       | string  | scheduled\_at | Sort field                                               |
| order      | string  | asc           | Sort order                                               |

**Response (200 OK):**

```json theme={null}
{
    "success": true,
    "data": [
        {
            "id": "sch_dd0e8400-e29b-41d4-a716-446655440007",
            "scheduled_date": "2026-01-15",
            "scheduled_time": "09:00:00",
            "scheduled_at": "2026-01-15T14:00:00Z",
            "timezone": "America/New_York",
            
            "topic": "Cloud Security Best Practices for 2026",
            "template_code": "EXEC_01",
            "tone": "authoritative",
            "keywords": ["cloud security", "cybersecurity", "data protection"],
            
            "auto_distribute": true,
            "distribution_channels": {
                "linkedin": true,
                "facebook": true,
                "twitter": false,
                "google_business": false
            },
            
            "status": "pending",
            "document_id": null,
            
            "import_batch_id": "batch_ee0e8400",
            "import_row_number": 15,
            
            "created_at": "2026-01-02T10:00:00Z",
            "created_by": {
                "id": "550e8400",
                "name": "John Doe"
            }
        }
    ],
    "pagination": {
        "page": 1,
        "per_page": 50,
        "total_items": 127,
        "total_pages": 3
    }
}
```

***

### POST /api/v1/clients/\{client\_id}/schedule

Create a single scheduled content item.

**Request:**

```json theme={null}
{
    "scheduled_date": "2026-01-20",
    "scheduled_time": "10:00:00",
    "timezone": "America/New_York",
    
    "topic": "The Future of Remote Work: Hybrid Strategies That Actually Work",
    "template_code": "MODERN_03",
    "tone": "professional",
    
    "keywords": ["remote work", "hybrid workplace", "employee engagement"],
    "related_services": ["Workplace Consulting", "HR Technology"],
    "custom_direction": "Include statistics about productivity and employee satisfaction",
    
    "auto_distribute": false,
    "distribution_channels": {
        "linkedin": true,
        "facebook": false,
        "twitter": false,
        "google_business": false
    }
}
```

**Response (201 Created):**

```json theme={null}
{
    "success": true,
    "data": {
        "id": "sch_ff0e8400-e29b-41d4-a716-446655440008",
        "scheduled_date": "2026-01-20",
        "scheduled_time": "10:00:00",
        "scheduled_at": "2026-01-20T15:00:00Z",
        "status": "pending",
        ...
    }
}
```

***

### POST /api/v1/clients/\{client\_id}/schedule/import

Import scheduled content from CSV.

**Request:** `multipart/form-data`

| Field    | Type    | Description                                |
| -------- | ------- | ------------------------------------------ |
| file     | file    | CSV file                                   |
| timezone | string  | Default timezone for rows without timezone |
| dry\_run | boolean | If true, validate only without creating    |

**CSV Format:**

```csv theme={null}
scheduled_date,scheduled_time,topic,template_code,tone,keywords,related_services,custom_direction,auto_distribute,linkedin,facebook,twitter,google_business
2026-01-15,09:00,AI Implementation Best Practices,EXEC_01,authoritative,"AI,implementation,strategy","AI Consulting,Training","Focus on ROI metrics",true,true,true,false,false
2026-01-16,09:00,Data Security in Cloud Computing,MINIMAL_02,professional,"cloud,security,compliance",,Include recent breach statistics,false,true,false,false,false
2026-01-17,09:00,Future of Remote Work,EXEC_01,casual,"remote work,hybrid,productivity","HR Consulting",,true,true,true,true,false
```

**Response (200 OK) - Dry Run:**

```json theme={null}
{
    "success": true,
    "data": {
        "dry_run": true,
        "valid_rows": 45,
        "invalid_rows": 3,
        "errors": [
            {
                "row": 12,
                "field": "template_code",
                "message": "Template 'INVALID_01' not found or not available"
            },
            {
                "row": 23,
                "field": "scheduled_date",
                "message": "Date '2025-12-15' is in the past"
            },
            {
                "row": 38,
                "field": "topic",
                "message": "Topic is required"
            }
        ],
        "warnings": [
            {
                "row": 5,
                "field": "keywords",
                "message": "No keywords provided, AI will determine keywords"
            }
        ],
        "preview": [
            {
                "row": 1,
                "scheduled_at": "2026-01-15T14:00:00Z",
                "topic": "AI Implementation Best Practices",
                "template": "Executive Professional"
            }
        ]
    }
}
```

**Response (201 Created) - Actual Import:**

```json theme={null}
{
    "success": true,
    "data": {
        "import_batch_id": "batch_gg0e8400-e29b-41d4-a716-446655440009",
        "imported_count": 45,
        "skipped_count": 3,
        "errors": [...],
        "first_scheduled_at": "2026-01-15T14:00:00Z",
        "last_scheduled_at": "2026-03-30T13:00:00Z"
    }
}
```

***

### PUT /api/v1/clients/\{client\_id}/schedule/\{schedule\_id}

Update a scheduled content item.

**Request:**

```json theme={null}
{
    "scheduled_date": "2026-01-21",
    "topic": "Updated Topic Title",
    "custom_direction": "New direction for content"
}
```

**Response (200 OK):** Updated schedule object

**Error:** `400 Bad Request` if status is not 'pending'

***

### DELETE /api/v1/clients/\{client\_id}/schedule/\{schedule\_id}

Cancel a scheduled content item.

**Response (200 OK):**

```json theme={null}
{
    "success": true,
    "data": {
        "message": "Scheduled content canceled",
        "id": "sch_ff0e8400",
        "status": "canceled"
    }
}
```

**Error:** `400 Bad Request` if already processing or completed

***

### DELETE /api/v1/clients/\{client\_id}/schedule/batch/\{batch\_id}

Cancel all pending items from an import batch.

**Response (200 OK):**

```json theme={null}
{
    "success": true,
    "data": {
        "batch_id": "batch_gg0e8400",
        "canceled_count": 38,
        "already_processed_count": 7,
        "message": "38 scheduled items canceled, 7 already processed"
    }
}
```

## 6.6 Template Endpoints

### GET /api/v1/templates

List available templates for the agency.

**Response (200 OK):**

```json theme={null}
{
    "success": true,
    "data": [
        {
            "id": "tmpl_hh0e8400-e29b-41d4-a716-446655440010",
            "code": "EXEC_01",
            "name": "Executive Professional",
            "description": "Clean, authoritative design perfect for C-suite audiences. Features prominent statistics callouts and professional typography.",
            "category": "executive",
            "tags": ["professional", "data-heavy", "charts"],
            
            "preview_image_url": "https://storage.../templates/exec_01_preview.png",
            "preview_pdf_url": "https://storage.../templates/exec_01_sample.pdf",
            
            "capabilities": {
                "supports_charts": true,
                "supports_statistics": true,
                "supports_quotes": true,
                "supports_tables": true,
                "max_sections": 10
            },
            
            "is_premium": false,
            "is_default": true
        },
        {
            "id": "tmpl_ii0e8400-e29b-41d4-a716-446655440011",
            "code": "MINIMAL_02",
            "name": "Minimal Modern",
            "description": "Contemporary minimalist design with generous whitespace. Ideal for thought leadership that lets the content breathe.",
            "category": "minimal",
            "tags": ["minimal", "modern", "clean"],
            
            "preview_image_url": "https://storage.../templates/minimal_02_preview.png",
            "preview_pdf_url": "https://storage.../templates/minimal_02_sample.pdf",
            
            "capabilities": {
                "supports_charts": true,
                "supports_statistics": true,
                "supports_quotes": true,
                "supports_tables": true,
                "max_sections": 8
            },
            
            "is_premium": false,
            "is_default": false
        },
        {
            "id": "tmpl_jj0e8400-e29b-41d4-a716-446655440012",
            "code": "BOLD_05",
            "name": "Bold Impact",
            "description": "High-impact design with bold typography and vibrant accent usage. Makes a strong visual statement.",
            "category": "creative",
            "tags": ["bold", "colorful", "impact"],
            
            "preview_image_url": "https://storage.../templates/bold_05_preview.png",
            
            "capabilities": {
                "supports_charts": true,
                "supports_statistics": true,
                "supports_quotes": true,
                "supports_tables": false,
                "max_sections": 6
            },
            
            "is_premium": true,
            "is_default": false
        }
    ]
}
```

***

### GET /api/v1/templates/\{template\_code}

Get template details.

**Response (200 OK):**

```json theme={null}
{
    "success": true,
    "data": {
        "id": "tmpl_hh0e8400-e29b-41d4-a716-446655440010",
        "code": "EXEC_01",
        "name": "Executive Professional",
        "description": "Clean, authoritative design perfect for C-suite audiences...",
        
        "preview_image_url": "https://storage.../templates/exec_01_preview.png",
        "preview_pdf_url": "https://storage.../templates/exec_01_sample.pdf",
        
        "capabilities": {
            "supports_charts": true,
            "chart_types": ["bar", "line", "donut", "comparison"],
            "supports_statistics": true,
            "max_statistics": 8,
            "supports_quotes": true,
            "max_quotes": 3,
            "supports_tables": true,
            "supports_callouts": true,
            "callout_types": ["tip", "warning", "note", "example"],
            "max_sections": 10,
            "recommended_sections": "5-7"
        },
        
        "color_scheme": {
            "primary_usage": "Headers, section labels, footer",
            "secondary_usage": "Callout backgrounds, chart accents",
            "accent_usage": "Links, highlights, statistics"
        },
        
        "sample_content": {
            "section_example": "...",
            "statistic_example": "..."
        },
        
        "is_premium": false,
        "version": 2,
        "last_updated": "2025-12-01T00:00:00Z"
    }
}
```

## 6.7 Agency Settings Endpoints

### GET /api/v1/agency

Get current agency details.

**Response (200 OK):**

```json theme={null}
{
    "success": true,
    "data": {
        "id": "660e8400-e29b-41d4-a716-446655440001",
        "name": "Acme Marketing Agency",
        "slug": "acme",
        
        "plan": {
            "id": "plan_kk0e8400",
            "name": "enterprise_annual",
            "display_name": "Enterprise (Annual)",
            "max_seats": 200,
            "max_templates": null,
            "custom_domain_allowed": true,
            "client_image_upload_allowed": true,
            "api_key_mode": "included"
        },
        
        "subscription": {
            "status": "active",
            "started_at": "2025-06-01T00:00:00Z",
            "current_period_start": "2026-01-01T00:00:00Z",
            "current_period_end": "2026-06-01T00:00:00Z"
        },
        
        "usage": {
            "seats_used": 45,
            "seats_available": 155,
            "api_credits_used_cents": 42500,
            "api_credits_limit_cents": 100000,
            "api_credits_percent_used": 42.5
        },
        
        "branding": {
            "color_mode": "light",
            "color_accent_1": "#1a4a6e",
            "color_accent_2": "#b8860b",
            "color_accent_3": "#2980b9",
            "logo_horizontal_url": "https://storage.../acme/logo-horizontal.png",
            "logo_vertical_url": "https://storage.../acme/logo-vertical.png",
            "logo_round_url": "https://storage.../acme/logo-round.png",
            "logo_favicon_url": "https://storage.../acme/favicon.png"
        },
        
        "company_info": {
            "website": "https://acmeagency.com",
            "email": "contact@acmeagency.com",
            "phone": "+1 555-123-4567",
            "address": "123 Marketing Ave, Suite 500\nNew York, NY 10001",
            "footer_text": "© 2026 Acme Marketing Agency. Confidential."
        },
        
        "social_links": {
            "linkedin": "https://linkedin.com/company/acme-agency",
            "facebook": "https://facebook.com/acmeagency",
            "twitter": "https://twitter.com/acmeagency",
            "instagram": null
        },
        
        "custom_domain": {
            "domain": "content.acmeagency.com",
            "verified": true,
            "verified_at": "2025-06-15T10:00:00Z"
        },
        
        "oauth_status": {
            "linkedin": {
                "configured": true,
                "configured_at": "2025-06-01T10:00:00Z"
            },
            "facebook": {
                "configured": true,
                "configured_at": "2025-06-01T10:05:00Z"
            },
            "twitter": {
                "configured": false
            },
            "google_business": {
                "configured": false
            }
        },
        
        "settings": {
            "default_timezone": "America/New_York",
            "notification_email": "notifications@acmeagency.com",
            "webhook_url": "https://acmeagency.com/webhooks/content-strategist",
            "webhook_configured": true
        },
        
        "api_keys": {
            "anthropic_configured": false,
            "anthropic_last4": null,
            "freepik_configured": true,
            "freepik_last4": "x7z9"
        },
        
        "created_at": "2025-06-01T00:00:00Z"
    }
}
```

***

### PUT /api/v1/agency

Update agency settings.

**Request:**

```json theme={null}
{
    "name": "Acme Marketing Agency",
    "company_website": "https://acmeagency.com",
    "company_email": "hello@acmeagency.com",
    "company_phone": "+1 555-123-4567",
    "company_address": "123 Marketing Ave, Suite 500\nNew York, NY 10001",
    "footer_text": "© 2026 Acme Marketing Agency. All rights reserved.",
    "default_timezone": "America/New_York",
    "notification_email": "alerts@acmeagency.com"
}
```

**Response (200 OK):** Updated agency object

***

### PUT /api/v1/agency/branding

Update agency branding (colors, logos).

**Request:**

```json theme={null}
{
    "color_mode": "dark",
    "color_accent_1": "#2563eb",
    "color_accent_2": "#d97706",
    "color_accent_3": "#059669"
}
```

**Response (200 OK):** Updated branding object

***

### POST /api/v1/agency/branding/logo

Upload agency logo.

**Request:** `multipart/form-data`

| Field | Type   | Description                                     |
| ----- | ------ | ----------------------------------------------- |
| file  | file   | Image file (JPG, PNG, WebP, SVG)                |
| type  | string | Logo type: horizontal, vertical, round, favicon |

**Response (200 OK):**

```json theme={null}
{
    "success": true,
    "data": {
        "type": "horizontal",
        "url": "https://storage.../acme/logo-horizontal.png",
        "width": 400,
        "height": 100,
        "file_size_bytes": 24576
    }
}
```

***

### PUT /api/v1/agency/api-keys

Update agency API keys (BYOK mode).

**Request:**

```json theme={null}
{
    "anthropic_api_key": "sk-ant-api03-xxxxxxxxxxxxx",
    "freepik_api_key": "fpk_xxxxxxxxxxxxx"
}
```

**Response (200 OK):**

```json theme={null}
{
    "success": true,
    "data": {
        "anthropic": {
            "configured": true,
            "last4": "xxxx",
            "updated_at": "2026-01-02T10:30:00Z"
        },
        "freepik": {
            "configured": true,
            "last4": "xxxx",
            "updated_at": "2026-01-02T10:30:00Z"
        }
    }
}
```

***

### POST /api/v1/agency/api-keys/test

Test API key validity.

**Request:**

```json theme={null}
{
    "provider": "anthropic",
    "api_key": "sk-ant-api03-xxxxxxxxxxxxx"
}
```

**Response (200 OK):**

```json theme={null}
{
    "success": true,
    "data": {
        "provider": "anthropic",
        "valid": true,
        "message": "API key is valid and has sufficient permissions"
    }
}
```

***

### GET /api/v1/agency/api-keys/list

List programmatic API keys for the agency.

**Response (200 OK):**

```json theme={null}
{
    "success": true,
    "data": [
        {
            "id": "key_ll0e8400-e29b-41d4-a716-446655440013",
            "name": "Production Integration",
            "key_prefix": "csa_live_ab",
            "scopes": ["documents:create", "documents:read", "clients:read"],
            "is_active": true,
            "expires_at": null,
            "last_used_at": "2026-01-02T09:45:00Z",
            "total_requests": 1247,
            "created_at": "2025-10-15T10:00:00Z",
            "created_by": {
                "id": "550e8400",
                "name": "John Doe"
            }
        }
    ]
}
```

***

### POST /api/v1/agency/api-keys

Create a new programmatic API key.

**Request:**

```json theme={null}
{
    "name": "Staging Environment",
    "scopes": ["documents:create", "documents:read", "schedule:create"],
    "expires_at": "2027-01-01T00:00:00Z",
    "allowed_ips": ["192.168.1.0/24"],
    "rate_limit_per_minute": 30
}
```

**Response (201 Created):**

```json theme={null}
{
    "success": true,
    "data": {
        "id": "key_mm0e8400-e29b-41d4-a716-446655440014",
        "name": "Staging Environment",
        "key": "csa_live_cd8f9a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p7q8r",
        "key_prefix": "csa_live_cd",
        "scopes": ["documents:create", "documents:read", "schedule:create"],
        "expires_at": "2027-01-01T00:00:00Z",
        "created_at": "2026-01-02T10:30:00Z",
        
        "warning": "This is the only time the full API key will be shown. Store it securely."
    }
}
```

***

### DELETE /api/v1/agency/api-keys/\{key\_id}

Revoke an API key.

**Response (200 OK):**

```json theme={null}
{
    "success": true,
    "data": {
        "message": "API key revoked",
        "id": "key_mm0e8400",
        "revoked_at": "2026-01-02T10:35:00Z"
    }
}
```

***

### POST /api/v1/agency/custom-domain

Configure custom domain (Enterprise only).

**Request:**

```json theme={null}
{
    "domain": "content.acmeagency.com"
}
```

**Response (200 OK):**

```json theme={null}
{
    "success": true,
    "data": {
        "domain": "content.acmeagency.com",
        "verification_status": "pending",
        "verification_method": "dns_txt",
        "verification_record": {
            "type": "TXT",
            "name": "_content-strategist-verify.content.acmeagency.com",
            "value": "content-strategist-verify=abc123xyz789def456"
        },
        "instructions": "Add this TXT record to your DNS configuration, then call the verify endpoint."
    }
}
```

***

### POST /api/v1/agency/custom-domain/verify

Verify custom domain DNS configuration.

**Response (200 OK):**

```json theme={null}
{
    "success": true,
    "data": {
        "domain": "content.acmeagency.com",
        "verified": true,
        "verified_at": "2026-01-02T11:00:00Z",
        "ssl_status": "provisioning",
        "ssl_ready_at": "2026-01-02T11:05:00Z"
    }
}
```

## 6.8 Team Management Endpoints

### GET /api/v1/agency/team

List team members.

**Response (200 OK):**

```json theme={null}
{
    "success": true,
    "data": [
        {
            "id": "550e8400-e29b-41d4-a716-446655440000",
            "email": "john@acmeagency.com",
            "first_name": "John",
            "last_name": "Doe",
            "display_name": "John Doe",
            "role": "agency_admin",
            "job_title": "CEO",
            "avatar_url": "https://storage.../avatars/550e8400.jpg",
            "is_active": true,
            "is_email_verified": true,
            "last_login_at": "2026-01-02T09:00:00Z",
            "created_at": "2025-06-01T00:00:00Z"
        },
        {
            "id": "551e8400-e29b-41d4-a716-446655440001",
            "email": "jane@acmeagency.com",
            "first_name": "Jane",
            "last_name": "Smith",
            "display_name": "Jane Smith",
            "role": "agency_member",
            "job_title": "Content Manager",
            "avatar_url": null,
            "is_active": true,
            "is_email_verified": true,
            "last_login_at": "2026-01-01T15:30:00Z",
            "created_at": "2025-07-15T00:00:00Z"
        }
    ]
}
```

***

### POST /api/v1/agency/team

Invite new team member.

**Request:**

```json theme={null}
{
    "email": "newmember@acmeagency.com",
    "first_name": "Bob",
    "last_name": "Johnson",
    "role": "agency_member",
    "job_title": "Account Executive"
}
```

**Response (201 Created):**

```json theme={null}
{
    "success": true,
    "data": {
        "id": "552e8400-e29b-41d4-a716-446655440002",
        "email": "newmember@acmeagency.com",
        "first_name": "Bob",
        "last_name": "Johnson",
        "role": "agency_member",
        "is_active": true,
        "is_email_verified": false,
        "invitation_sent": true,
        "invitation_expires_at": "2026-01-09T10:30:00Z"
    }
}
```

***

### PUT /api/v1/agency/team/\{user\_id}

Update team member.

**Request:**

```json theme={null}
{
    "role": "agency_admin",
    "job_title": "Director of Content"
}
```

**Response (200 OK):** Updated user object

***

### DELETE /api/v1/agency/team/\{user\_id}

Deactivate team member.

**Response (200 OK):**

```json theme={null}
{
    "success": true,
    "data": {
        "message": "Team member deactivated",
        "id": "552e8400"
    }
}
```

## 6.9 Admin Endpoints (Super Admin Only)

### GET /api/v1/admin/agencies

List all agencies.

**Query Parameters:**

| Parameter | Type    | Description                    |
| --------- | ------- | ------------------------------ |
| page      | integer | Page number                    |
| per\_page | integer | Items per page                 |
| search    | string  | Search in name                 |
| plan      | string  | Filter by plan name            |
| status    | string  | Filter by subscription\_status |
| sort      | string  | Sort field                     |
| order     | string  | Sort order                     |

**Response (200 OK):**

```json theme={null}
{
    "success": true,
    "data": [
        {
            "id": "660e8400-e29b-41d4-a716-446655440001",
            "name": "Acme Marketing Agency",
            "slug": "acme",
            "plan": {
                "name": "enterprise_annual",
                "display_name": "Enterprise (Annual)"
            },
            "subscription_status": "active",
            "subscription_ends_at": "2026-06-01T00:00:00Z",
            "seats_used": 45,
            "seats_limit": 200,
            "documents_generated_total": 1247,
            "api_usage_current_period_cents": 42500,
            "created_at": "2025-06-01T00:00:00Z",
            "last_activity_at": "2026-01-02T09:45:00Z"
        }
    ],
    "pagination": {...}
}
```

***

### POST /api/v1/admin/agencies

Create new agency.

**Request:**

```json theme={null}
{
    "name": "New Agency Inc",
    "slug": "new-agency",
    "plan_id": "plan_nn0e8400",
    "admin_email": "admin@newagency.com",
    "admin_name": "Admin User"
}
```

**Response (201 Created):** Full agency object with admin user

***

### GET /api/v1/admin/agencies/\{agency\_id}

Get agency details (admin view).

**Response (200 OK):** Full agency object with all settings, usage stats, and notes

***

### PUT /api/v1/admin/agencies/\{agency\_id}

Update agency (admin actions).

**Request:**

```json theme={null}
{
    "plan_id": "plan_oo0e8400",
    "subscription_status": "active",
    "notes": "Upgraded from Pro to Enterprise per sales call 2026-01-02"
}
```

***

### POST /api/v1/admin/agencies/\{agency\_id}/templates

Assign templates to agency (Pro plan only).

**Request:**

```json theme={null}
{
    "template_ids": [
        "tmpl_hh0e8400",
        "tmpl_ii0e8400",
        "tmpl_jj0e8400"
    ]
}
```

***

### GET /api/v1/admin/templates

List all templates.

**Response (200 OK):** All templates with usage statistics

***

### POST /api/v1/admin/templates

Create new template.

**Request:**

```json theme={null}
{
    "code": "CORP_06",
    "name": "Corporate Classic",
    "description": "Traditional corporate design...",
    "category": "corporate",
    "tags": ["corporate", "traditional", "formal"],
    "html_template": "<!DOCTYPE html>...",
    "css_template": "/* styles */...",
    "is_premium": true
}
```

***

### PUT /api/v1/admin/templates/\{template\_id}

Update template.

***

### GET /api/v1/admin/system/stats

Get system statistics.

**Response (200 OK):**

```json theme={null}
{
    "success": true,
    "data": {
        "agencies": {
            "total": 127,
            "active": 118,
            "by_plan": {
                "pro_annual": 45,
                "pro_monthly": 32,
                "enterprise_annual": 28,
                "enterprise_monthly": 13
            }
        },
        "clients": {
            "total": 4523,
            "active": 4201
        },
        "documents": {
            "total": 89247,
            "last_24h": 342,
            "last_7d": 2156,
            "last_30d": 8934,
            "by_status": {
                "ready": 45230,
                "distributed": 42891,
                "failed": 1126
            }
        },
        "api_usage": {
            "total_cost_cents_this_month": 4523000,
            "total_tokens_this_month": 125000000
        },
        "storage": {
            "total_bytes": 524288000000,
            "documents_bytes": 498073600000,
            "logos_bytes": 26214400000
        }
    }
}
```

***

# 7. Content Generation Pipeline

## 7.1 Pipeline Overview

The content generation pipeline is the core of the system. It transforms a topic into a professionally designed PDF document through a series of coordinated steps.

### 7.1.1 Generation Steps Summary

| Step | ID                       | Description                         | Weight | Typical Duration |
| ---- | ------------------------ | ----------------------------------- | ------ | ---------------- |
| 1    | `topic_analysis`         | Analyze topic and determine scope   | 5%     | 5-10s            |
| 2    | `keyword_research`       | Identify relevant keywords          | 5%     | 5-10s            |
| 3    | `web_research`           | Deep research from multiple sources | 30%    | 30-120s          |
| 4    | `industry_analysis`      | Analyze industry-specific reports   | 10%    | 10-20s           |
| 5    | `outline_creation`       | Create content structure            | 5%     | 5-10s            |
| 6    | `content_writing`        | Generate all written content        | 25%    | 30-60s           |
| 7    | `statistics_integration` | Add statistics and data             | 5%     | 5-10s            |
| 8    | `chart_generation`       | Create data visualizations          | 5%     | 10-20s           |
| 9    | `cover_image`            | Select/generate cover image         | 3%     | 5-15s            |
| 10   | `template_application`   | Apply design template               | 4%     | 5-10s            |
| 11   | `pdf_rendering`          | Render final PDF                    | 2%     | 5-15s            |
| 12   | `quality_review`         | Final quality check                 | 1%     | 2-5s             |

**Total Typical Duration:** 2-5 minutes

### 7.1.2 Pipeline Architecture

```
┌─────────────────────────────────────────────────────────────────────────────────┐
│                          GENERATION PIPELINE ARCHITECTURE                        │
└─────────────────────────────────────────────────────────────────────────────────┘

    API Request                     Celery Worker                    External Services
         │                               │                                  │
         ▼                               │                                  │
┌─────────────────┐                      │                                  │
│ POST /generate  │                      │                                  │
│                 │                      │                                  │
│ • Validate input│                      │                                  │
│ • Create Document                      │                                  │
│ • Create Job    │                      │                                  │
│ • Queue task    │                      │                                  │
└────────┬────────┘                      │                                  │
         │                               │                                  │
         │  Celery Task                  │                                  │
         └──────────────────────────────►│                                  │
                                         │                                  │
                                         ▼                                  │
                              ┌─────────────────────┐                       │
                              │  1. TOPIC ANALYSIS  │                       │
                              │                     │                       │
                              │  • Parse topic      │                       │
                              │  • Identify scope   │◄──────────────────────┤
                              │  • Determine depth  │     Claude API        │
                              │  • Extract entities │                       │
                              └──────────┬──────────┘                       │
                                         │                                  │
                                         ▼                                  │
                              ┌─────────────────────┐                       │
                              │ 2. KEYWORD RESEARCH │                       │
                              │                     │                       │
                              │  • Expand keywords  │◄──────────────────────┤
                              │  • Find related     │     Claude API        │
                              │  • Prioritize       │                       │
                              └──────────┬──────────┘                       │
                                         │                                  │
                                         ▼                                  │
                              ┌─────────────────────┐                       │
                              │  3. WEB RESEARCH    │                       │
                              │                     │                       │
                              │  • Search queries   │◄──────────────────────┤
                              │  • Fetch pages      │     Web Search API    │
                              │  • Extract content  │     (via Claude)      │
                              │  • Score relevance  │                       │
                              │  • 20-500 sources   │                       │
                              └──────────┬──────────┘                       │
                                         │                                  │
                                         ▼                                  │
                              ┌─────────────────────┐                       │
                              │ 4. INDUSTRY ANALYSIS│                       │
                              │                     │                       │
                              │  • Find reports     │◄──────────────────────┤
                              │  • Extract trends   │     Claude API        │
                              │  • Industry stats   │                       │
                              └──────────┬──────────┘                       │
                                         │                                  │
                                         ▼                                  │
                              ┌─────────────────────┐                       │
                              │ 5. OUTLINE CREATION │                       │
                              │                     │                       │
                              │  • Structure doc    │◄──────────────────────┤
                              │  • Plan sections    │     Claude API        │
                              │  • Allocate stats   │                       │
                              └──────────┬──────────┘                       │
                                         │                                  │
                                         ▼                                  │
                              ┌─────────────────────┐                       │
                              │ 6. CONTENT WRITING  │                       │
                              │                     │                       │
                              │  • Write sections   │◄──────────────────────┤
                              │  • Add quotes       │     Claude API        │
                              │  • Create callouts  │     (Multiple calls)  │
                              │  • Write conclusion │                       │
                              └──────────┬──────────┘                       │
                                         │                                  │
                                         ▼                                  │
                              ┌─────────────────────┐                       │
                              │ 7. STATISTICS       │                       │
                              │                     │                       │
                              │  • Format stats     │◄──────────────────────┤
                              │  • Verify sources   │     Claude API        │
                              │  • Add context      │                       │
                              └──────────┬──────────┘                       │
                                         │                                  │
                                         ▼                                  │
                              ┌─────────────────────┐                       │
                              │ 8. CHART GENERATION │                       │
                              │                     │                       │
                              │  • Select chart type│                       │
                              │  • Format data      │     Local             │
                              │  • Render SVG       │     (Matplotlib/      │
                              │  • Apply colors     │      Plotly)          │
                              └──────────┬──────────┘                       │
                                         │                                  │
                                         ▼                                  │
                              ┌─────────────────────┐                       │
                              │ 9. COVER IMAGE      │                       │
                              │                     │                       │
                              │  • Search Freepik   │◄──────────────────────┤
                              │  • Select image     │     Freepik API       │
                              │  • Download         │                       │
                              │  • Process          │                       │
                              └──────────┬──────────┘                       │
                                         │                                  │
                                         ▼                                  │
                              ┌─────────────────────┐                       │
                              │ 10. TEMPLATE        │                       │
                              │                     │                       │
                              │  • Load template    │                       │
                              │  • Inject content   │     Local             │
                              │  • Apply branding   │     (Jinja2)          │
                              │  • Generate HTML    │                       │
                              └──────────┬──────────┘                       │
                                         │                                  │
                                         ▼                                  │
                              ┌─────────────────────┐                       │
                              │ 11. PDF RENDERING   │                       │
                              │                     │                       │
                              │  • Render HTML      │                       │
                              │  • Apply CSS        │     Local             │
                              │  • Generate pages   │     (WeasyPrint)      │
                              │  • Optimize file    │                       │
                              └──────────┬──────────┘                       │
                                         │                                  │
                                         ▼                                  │
                              ┌─────────────────────┐                       │
                              │ 12. QUALITY REVIEW  │                       │
                              │                     │                       │
                              │  • Check completeness                       │
                              │  • Verify rendering │     Local             │
                              │  • Final validation │                       │
                              └──────────┬──────────┘                       │
                                         │                                  │
                                         ▼                                  │
                              ┌─────────────────────┐                       │
                              │     COMPLETE        │                       │
                              │                     │                       │
                              │  • Update document  │                       │
                              │  • Store PDF        │                       │
                              │  • Notify client    │                       │
                              │  • WebSocket update │                       │
                              └─────────────────────┘                       │
```

## 7.2 Step Details

### 7.2.1 Step 1: Topic Analysis

**Purpose:** Understand the topic scope and determine research depth.

**Input:**

```python theme={null}
{
    "topic": "AI Implementation Strategies for Mid-Market Companies",
    "industry": "Technology",
    "custom_direction": "Focus on practical steps and include case studies",
    "additional_context": "Target audience is C-level executives"
}
```

**Process:**

```python theme={null}
async def analyze_topic(input_data: GenerationInput) -> TopicAnalysis:
    """
    Analyze the topic to determine scope and research requirements.
    """
    
    prompt = f"""Analyze this content topic and provide structured analysis.

Topic: {input_data.topic}
Industry: {input_data.industry or 'General'}
Custom Direction: {input_data.custom_direction or 'None'}
Additional Context: {input_data.additional_context or 'None'}

Provide analysis in the following JSON format:
{{
    "refined_topic": "Clear, specific topic statement",
    "topic_scope": "narrow|medium|broad",
    "complexity_level": "basic|intermediate|advanced",
    "target_audience": "Description of ideal reader",
    "key_concepts": ["concept1", "concept2", ...],
    "related_topics": ["related1", "related2", ...],
    "recommended_sections": 5-10,
    "research_depth": "light|moderate|deep|comprehensive",
    "estimated_sources_needed": 20-500,
    "content_angle": "Description of unique angle to take",
    "potential_statistics_topics": ["stat_topic1", "stat_topic2", ...],
    "industry_specific_considerations": ["consideration1", ...]
}}
"""

    response = await claude_client.messages.create(
        model="claude-sonnet-4-20250514",
        max_tokens=2000,
        messages=[{"role": "user", "content": prompt}]
    )
    
    return TopicAnalysis.parse_raw(response.content[0].text)
```

**Output:**

```python theme={null}
TopicAnalysis(
    refined_topic="Strategic AI Implementation for Mid-Market Enterprises: A Practical Framework",
    topic_scope="medium",
    complexity_level="intermediate",
    target_audience="C-level executives and senior leadership at companies with 500-5000 employees",
    key_concepts=["AI strategy", "implementation roadmap", "ROI measurement", "change management", "vendor selection"],
    related_topics=["digital transformation", "data infrastructure", "machine learning operations"],
    recommended_sections=7,
    research_depth="deep",
    estimated_sources_needed=75,
    content_angle="Practical, action-oriented guide with emphasis on avoiding common pitfalls",
    potential_statistics_topics=["AI adoption rates", "implementation success rates", "ROI benchmarks"],
    industry_specific_considerations=["Technology sector early adoption", "Competitive pressure factors"]
)
```

### 7.2.2 Step 2: Keyword Research

**Purpose:** Expand and prioritize keywords for research targeting.

**Process:**

```python theme={null}
async def research_keywords(
    topic_analysis: TopicAnalysis,
    user_keywords: List[str]
) -> KeywordResearch:
    """
    Expand provided keywords and discover additional relevant terms.
    """
    
    prompt = f"""Based on this topic analysis, provide comprehensive keyword research.

Topic: {topic_analysis.refined_topic}
User-provided keywords: {user_keywords}
Key concepts: {topic_analysis.key_concepts}
Industry: {topic_analysis.industry_specific_considerations}

Provide keywords in JSON format:
{{
    "primary_keywords": [
        {{"term": "keyword", "search_priority": 1-10, "intent": "informational|commercial|navigational"}}
    ],
    "secondary_keywords": [...],
    "long_tail_keywords": [...],
    "question_keywords": ["how to...", "what is...", ...],
    "industry_terms": [...],
    "trending_terms": [...],
    "search_queries": [
        "Complete search query to use for research"
    ]
}}
"""

    response = await claude_client.messages.create(
        model="claude-sonnet-4-20250514",
        max_tokens=2000,
        messages=[{"role": "user", "content": prompt}]
    )
    
    return KeywordResearch.parse_raw(response.content[0].text)
```

### 7.2.3 Step 3: Web Research (CRITICAL STEP)

**Purpose:** Conduct deep, comprehensive research. This is the primary differentiator.

**Key Principle:** Research depth is NOT predetermined. It scales with topic complexity.

**Process:**

```python theme={null}
async def conduct_web_research(
    topic_analysis: TopicAnalysis,
    keyword_research: KeywordResearch,
    client: Client
) -> ResearchResult:
    """
    Conduct deep web research.
    
    THIS IS NOT SHALLOW BLOG SCRAPING.
    This is comprehensive research that may read 20-500+ sources
    depending on topic complexity.
    """
    
    # Determine research scope
    target_sources = topic_analysis.estimated_sources_needed
    min_sources = max(20, int(target_sources * 0.5))
    max_sources = min(500, int(target_sources * 2))
    
    all_sources = []
    search_queries = keyword_research.search_queries
    
    # Phase 1: Broad search across all queries
    for query in search_queries:
        search_results = await web_search(query, max_results=20)
        all_sources.extend(search_results)
    
    # Deduplicate by URL
    unique_sources = deduplicate_sources(all_sources)
    
    # Phase 2: Score and rank sources by relevance
    scored_sources = await score_source_relevance(
        sources=unique_sources,
        topic=topic_analysis.refined_topic,
        key_concepts=topic_analysis.key_concepts
    )
    
    # Phase 3: Deep read of top sources
    sources_to_read = scored_sources[:max_sources]
    
    detailed_content = []
    for source in sources_to_read:
        try:
            content = await fetch_and_extract_content(source.url)
            
            # Extract relevant sections
            relevant_sections = await extract_relevant_sections(
                content=content,
                topic=topic_analysis.refined_topic,
                key_concepts=topic_analysis.key_concepts
            )
            
            if relevant_sections:
                detailed_content.append(SourceContent(
                    url=source.url,
                    title=source.title,
                    domain=source.domain,
                    content=relevant_sections,
                    relevance_score=source.relevance_score,
                    accessed_at=datetime.utcnow()
                ))
                
        except Exception as e:
            logger.warning(f"Failed to fetch {source.url}: {e}")
            continue
        
        # Check if we have enough high-quality sources
        if len(detailed_content) >= target_sources:
            break
    
    # Phase 4: Extract insights, statistics, and quotes
    insights = await extract_insights(detailed_content, topic_analysis)
    statistics = await extract_statistics(detailed_content)
    quotes = await extract_expert_quotes(detailed_content)
    
    # Phase 5: Synthesize findings
    synthesis = await synthesize_research(
        sources=detailed_content,
        insights=insights,
        topic=topic_analysis
    )
    
    return ResearchResult(
        sources=detailed_content,
        source_count=len(detailed_content),
        insights=insights,
        statistics=statistics,
        quotes=quotes,
        synthesis=synthesis,
        research_duration_seconds=elapsed_time
    )
```

**Source Scoring Algorithm:**

```python theme={null}
async def score_source_relevance(
    sources: List[SearchResult],
    topic: str,
    key_concepts: List[str]
) -> List[ScoredSource]:
    """
    Score sources by relevance and authority.
    """
    
    scored = []
    
    for source in sources:
        score = 0.0
        
        # Domain authority (predefined list)
        domain_scores = {
            "mckinsey.com": 0.95,
            "hbr.org": 0.95,
            "gartner.com": 0.90,
            "forrester.com": 0.90,
            "deloitte.com": 0.90,
            "pwc.com": 0.88,
            "accenture.com": 0.85,
            "mit.edu": 0.90,
            "stanford.edu": 0.90,
            "nature.com": 0.92,
            "sciencedirect.com": 0.88,
            "forbes.com": 0.75,
            "techcrunch.com": 0.72,
            "wired.com": 0.70,
            # ... more domains
        }
        
        domain = extract_domain(source.url)
        authority_score = domain_scores.get(domain, 0.5)
        
        # Title relevance
        title_relevance = calculate_text_similarity(source.title, topic)
        
        # Snippet relevance
        snippet_relevance = calculate_text_similarity(source.snippet, topic)
        
        # Concept coverage
        concept_coverage = sum(
            1 for concept in key_concepts 
            if concept.lower() in source.snippet.lower()
        ) / len(key_concepts)
        
        # Recency bonus (prefer recent content)
        if source.published_date:
            days_old = (datetime.now() - source.published_date).days
            recency_score = max(0, 1 - (days_old / 365))  # Decay over 1 year
        else:
            recency_score = 0.5
        
        # Weighted final score
        final_score = (
            authority_score * 0.30 +
            title_relevance * 0.25 +
            snippet_relevance * 0.20 +
            concept_coverage * 0.15 +
            recency_score * 0.10
        )
        
        scored.append(ScoredSource(
            **source.dict(),
            relevance_score=final_score
        ))
    
    # Sort by score descending
    return sorted(scored, key=lambda x: x.relevance_score, reverse=True)
```

### 7.2.4 Step 4: Industry Analysis

**Purpose:** Extract industry-specific insights and benchmarks.

```python theme={null}
async def analyze_industry(
    research: ResearchResult,
    topic_analysis: TopicAnalysis,
    industry: str
) -> IndustryAnalysis:
    """
    Analyze industry-specific context and benchmarks.
    """
    
    # Filter sources for industry reports
    industry_sources = [
        s for s in research.sources 
        if is_industry_report(s) or contains_industry_data(s, industry)
    ]
    
    prompt = f"""Analyze the industry context for this content.

Industry: {industry}
Topic: {topic_analysis.refined_topic}

Based on the research, provide:
{{
    "industry_overview": "Brief overview of industry context",
    "current_trends": ["trend1", "trend2", ...],
    "challenges": ["challenge1", "challenge2", ...],
    "opportunities": ["opportunity1", ...],
    "benchmarks": [
        {{"metric": "Metric name", "value": "Value", "source": "Source"}}
    ],
    "competitive_landscape": "Brief competitive analysis",
    "future_outlook": "Industry direction",
    "key_players": ["company1", "company2", ...],
    "regulatory_considerations": ["consideration1", ...]
}}

Research excerpts:
{format_sources_for_prompt(industry_sources[:10])}
"""

    response = await claude_client.messages.create(
        model="claude-sonnet-4-20250514",
        max_tokens=3000,
        messages=[{"role": "user", "content": prompt}]
    )
    
    return IndustryAnalysis.parse_raw(response.content[0].text)
```

### 7.2.5 Step 5: Outline Creation

**Purpose:** Structure the document based on research findings.

```python theme={null}
async def create_outline(
    topic_analysis: TopicAnalysis,
    research: ResearchResult,
    industry_analysis: IndustryAnalysis,
    client: Client
) -> ContentOutline:
    """
    Create structured outline for the document.
    """
    
    prompt = f"""Create a detailed content outline for this thought leadership piece.

Topic: {topic_analysis.refined_topic}
Target Audience: {topic_analysis.target_audience}
Content Angle: {topic_analysis.content_angle}
Recommended Sections: {topic_analysis.recommended_sections}

Key Insights from Research:
{format_insights(research.insights[:15])}

Industry Context:
- Trends: {industry_analysis.current_trends}
- Challenges: {industry_analysis.challenges}

Available Statistics:
{format_statistics(research.statistics[:10])}

Client Services to Potentially Reference:
{client.related_services}

Create an outline in this JSON format:
{{
    "title": "Compelling document title",
    "subtitle": "Explanatory subtitle",
    "executive_summary_points": ["point1", "point2", "point3"],
    
    "sections": [
        {{
            "number": 1,
            "label": "Strategy 1:" or null,
            "title": "Section title",
            "key_point": "Main takeaway",
            "subsections": ["subtopic1", "subtopic2"],
            "statistics_to_include": ["stat_id1", "stat_id2"],
            "include_chart": true/false,
            "chart_type": "bar|line|donut|comparison",
            "include_callout": true/false,
            "callout_type": "tip|warning|note|example",
            "estimated_word_count": 300-500
        }}
    ],
    
    "conclusion": {{
        "title": "The Bottom Line",
        "key_takeaways": ["takeaway1", "takeaway2", "takeaway3"],
        "call_to_action": "CTA text"
    }},
    
    "statistics_placement": [
        {{"stat_id": "stat_1", "section": 2, "display_type": "callout|inline|chart"}}
    ],
    
    "quotes_placement": [
        {{"quote_id": "quote_1", "section": 3}}
    ]
}}
"""

    response = await claude_client.messages.create(
        model="claude-sonnet-4-20250514",
        max_tokens=4000,
        messages=[{"role": "user", "content": prompt}]
    )
    
    return ContentOutline.parse_raw(response.content[0].text)
```

### 7.2.6 Step 6: Content Writing

**Purpose:** Generate all written content based on outline and research.

```python theme={null}
async def write_content(
    outline: ContentOutline,
    research: ResearchResult,
    industry_analysis: IndustryAnalysis,
    topic_analysis: TopicAnalysis,
    client: Client
) -> WrittenContent:
    """
    Write all content sections.
    This is typically done in multiple API calls to maintain quality.
    """
    
    written_sections = []
    
    # Write executive summary first
    summary = await write_executive_summary(outline, research, topic_analysis)
    
    # Write each section
    for section in outline.sections:
        # Get relevant research for this section
        section_research = get_research_for_section(research, section)
        section_stats = get_statistics_for_section(research.statistics, section)
        
        section_content = await write_section(
            section=section,
            research=section_research,
            statistics=section_stats,
            industry=industry_analysis,
            tone=topic_analysis.target_audience,
            client_services=client.related_services
        )
        
        written_sections.append(section_content)
    
    # Write conclusion
    conclusion = await write_conclusion(outline.conclusion, written_sections, client)
    
    return WrittenContent(
        title=outline.title,
        subtitle=outline.subtitle,
        summary=summary,
        sections=written_sections,
        conclusion=conclusion
    )


async def write_section(
    section: OutlineSection,
    research: List[SourceContent],
    statistics: List[Statistic],
    industry: IndustryAnalysis,
    tone: str,
    client_services: List[str]
) -> WrittenSection:
    """
    Write a single content section.
    """
    
    prompt = f"""Write a section for a thought leadership document.

Section Title: {section.title}
Section Label: {section.label or 'None'}
Key Point: {section.key_point}
Subsections to Cover: {section.subsections}
Target Word Count: {section.estimated_word_count}

Tone: Write for {tone}. Be authoritative but accessible.

Research to Reference:
{format_research_excerpts(research)}

Statistics Available:
{format_statistics(statistics)}

Industry Context:
{industry.industry_overview}

If naturally relevant, you may reference these services: {client_services}
Do NOT make this a sales pitch. Only mention if genuinely relevant.

Write in this JSON format:
{{
    "lead_paragraph": "Bold, engaging opening statement (1-2 sentences)",
    "body_paragraphs": [
        "Paragraph 1 text...",
        "Paragraph 2 text...",
        ...
    ],
    "subsections": [
        {{
            "title": "Subsection title",
            "content": "Subsection content..."
        }}
    ],
    "callout": {{
        "type": "tip|warning|note|example",
        "content": "Callout content if include_callout is true"
    }} or null,
    "statistics_used": ["stat_id1", "stat_id2"],
    "sources_cited": ["url1", "url2"]
}}

Requirements:
- Use specific data and examples, not vague claims
- Include statistics naturally in the flow
- Write with authority and expertise
- Avoid clichés and filler phrases
- Every claim should be backed by research
"""

    response = await claude_client.messages.create(
        model="claude-sonnet-4-20250514",
        max_tokens=3000,
        messages=[{"role": "user", "content": prompt}]
    )
    
    return WrittenSection.parse_raw(response.content[0].text)
```

### 7.2.7 Step 7: Statistics Integration

**Purpose:** Format and verify all statistics for display.

```python theme={null}
async def integrate_statistics(
    content: WrittenContent,
    research: ResearchResult
) -> List[FormattedStatistic]:
    """
    Format statistics for visual display in the document.
    """
    
    # Get all statistics referenced in content
    used_stat_ids = set()
    for section in content.sections:
        used_stat_ids.update(section.statistics_used)
    
    formatted_stats = []
    
    for stat in research.statistics:
        if stat.id in used_stat_ids:
            formatted = FormattedStatistic(
                id=stat.id,
                value=format_stat_value(stat.value),  # "73%" or "$1.2M"
                label=stat.label,
                source=stat.source,
                source_url=stat.source_url,
                display_type=determine_display_type(stat),  # callout, inline, chart
                color_category=determine_color_category(stat),  # primary, secondary, accent
                context=stat.context
            )
            formatted_stats.append(formatted)
    
    return formatted_stats
```

### 7.2.8 Step 8: Chart Generation

**Purpose:** Create data visualizations from statistics.

```python theme={null}
async def generate_charts(
    outline: ContentOutline,
    statistics: List[FormattedStatistic],
    branding: BrandingConfig
) -> List[GeneratedChart]:
    """
    Generate chart images for the document.
    """
    
    charts = []
    
    for section in outline.sections:
        if section.include_chart:
            # Get statistics for this chart
            chart_stats = [s for s in statistics if s.id in section.statistics_to_include]
            
            if chart_stats:
                chart = await create_chart(
                    chart_type=section.chart_type,
                    statistics=chart_stats,
                    title=f"{section.title} - Key Metrics",
                    colors=branding.chart_colors,
                    font_family=branding.font_family
                )
                charts.append(chart)
    
    return charts


async def create_chart(
    chart_type: str,
    statistics: List[FormattedStatistic],
    title: str,
    colors: ChartColors,
    font_family: str
) -> GeneratedChart:
    """
    Create a single chart using Matplotlib/Plotly.
    """
    
    import matplotlib.pyplot as plt
    import matplotlib
    matplotlib.use('Agg')  # Non-interactive backend
    
    # Set up figure
    fig, ax = plt.subplots(figsize=(8, 5), dpi=150)
    
    # Apply branding colors
    plt.rcParams['font.family'] = font_family
    
    if chart_type == "bar":
        labels = [s.label for s in statistics]
        values = [parse_numeric_value(s.value) for s in statistics]
        
        bars = ax.barh(labels, values, color=colors.primary)
        
        # Add value labels
        for bar, value in zip(bars, values):
            ax.text(bar.get_width() + 0.5, bar.get_y() + bar.get_height()/2,
                   f'{value}%', va='center', fontsize=10)
    
    elif chart_type == "donut":
        values = [parse_numeric_value(s.value) for s in statistics]
        labels = [s.label for s in statistics]
        
        wedges, texts, autotexts = ax.pie(
            values, 
            labels=labels,
            autopct='%1.1f%%',
            colors=[colors.primary, colors.secondary, colors.accent],
            wedgeprops=dict(width=0.5)  # Makes it a donut
        )
    
    elif chart_type == "comparison":
        # Side-by-side comparison chart
        # ... implementation
        pass
    
    elif chart_type == "line":
        # Trend line chart
        # ... implementation
        pass
    
    ax.set_title(title, fontsize=14, fontweight='bold', color=colors.text)
    
    # Remove spines for cleaner look
    ax.spines['top'].set_visible(False)
    ax.spines['right'].set_visible(False)
    
    # Save to bytes
    buffer = io.BytesIO()
    plt.savefig(buffer, format='png', bbox_inches='tight', transparent=True)
    buffer.seek(0)
    
    # Also generate SVG for PDF
    svg_buffer = io.BytesIO()
    plt.savefig(svg_buffer, format='svg', bbox_inches='tight')
    svg_buffer.seek(0)
    
    plt.close()
    
    return GeneratedChart(
        id=f"chart_{uuid.uuid4().hex[:8]}",
        type=chart_type,
        title=title,
        png_data=buffer.getvalue(),
        svg_data=svg_buffer.getvalue(),
        width=800,
        height=500
    )
```

### 7.2.9 Step 9: Cover Image

**Purpose:** Select and prepare cover image.

```python theme={null}
async def get_cover_image(
    topic: str,
    industry: str,
    branding: BrandingConfig,
    client: Client,
    cover_image_search: Optional[str] = None
) -> CoverImage:
    """
    Get cover image from Freepik or client upload.
    """
    
    # Check for client-uploaded image (Enterprise only)
    if client.custom_cover_image_url:
        return CoverImage(
            url=client.custom_cover_image_url,
            source="uploaded",
            attribution=None
        )
    
    # Search Freepik for appropriate image
    search_query = cover_image_search or f"{industry} {topic.split()[0]} business professional"
    
    freepik_results = await freepik_client.search(
        query=search_query,
        filters={
            "content_type": "photo",
            "orientation": "horizontal",
            "color": extract_dominant_color(branding.color_accent_1),
            "people": "no_people"  # Often cleaner for covers
        },
        limit=10
    )
    
    if not freepik_results:
        # Fallback to solid color cover
        return CoverImage(
            url=None,
            source="solid_color",
            color=branding.color_accent_1,
            attribution=None
        )
    
    # Select best match (first result usually best)
    selected = freepik_results[0]
    
    # Download high-res version
    image_data = await freepik_client.download(selected.id, size="large")
    
    # Store locally
    cover_path = await storage_service.store_cover_image(
        image_data=image_data,
        document_id=document_id,
        client_id=client.id
    )
    
    return CoverImage(
        url=cover_path,
        source="freepik",
        freepik_id=selected.id,
        attribution=selected.attribution
    )
```

### 7.2.10 Step 10: Template Application

**Purpose:** Combine content with design template.

```python theme={null}
async def apply_template(
    template: Template,
    content: WrittenContent,
    statistics: List[FormattedStatistic],
    charts: List[GeneratedChart],
    cover_image: CoverImage,
    branding: BrandingConfig,
    client: Client
) -> str:
    """
    Apply design template to generate HTML.
    """
    
    # Load Jinja2 template
    jinja_env = Environment(
        loader=FileSystemLoader(f"templates/{template.code}"),
        autoescape=True
    )
    
    html_template = jinja_env.get_template("template.html")
    
    # Prepare template context
    context = {
        # Content
        "title": content.title,
        "subtitle": content.subtitle,
        "summary": content.summary,
        "sections": content.sections,
        "conclusion": content.conclusion,
        
        # Visuals
        "statistics": statistics,
        "charts": charts,
        "cover_image": cover_image,
        
        # Branding
        "colors": {
            "primary": branding.color_accent_1,
            "secondary": branding.color_accent_2,
            "accent": branding.color_accent_3,
            "text": branding.text_color,
            "background": branding.background_color,
        },
        "logos": {
            "horizontal": branding.logo_horizontal_url,
            "vertical": branding.logo_vertical_url,
        },
        "footer_text": branding.footer_text or client.footer_text,
        
        # Metadata
        "generated_date": datetime.now().strftime("%B %d, %Y"),
        "company_name": client.company_name,
        
        # Helper functions
        "format_stat": format_stat_for_display,
        "get_chart_by_section": lambda s: next((c for c in charts if c.section == s), None),
    }
    
    # Render HTML
    html_content = html_template.render(**context)
    
    # Inject CSS variables for colors
    css_variables = f"""
    <style>
        :root {{
            --color-primary: {branding.color_accent_1};
            --color-secondary: {branding.color_accent_2};
            --color-accent: {branding.color_accent_3};
            --color-text: {branding.text_color};
            --color-background: {branding.background_color};
            --color-primary-light: {lighten_color(branding.color_accent_1, 0.9)};
            --color-secondary-light: {lighten_color(branding.color_accent_2, 0.9)};
        }}
    </style>
    """
    
    # Insert CSS variables into head
    html_content = html_content.replace("</head>", f"{css_variables}</head>")
    
    return html_content
```

### 7.2.11 Step 11: PDF Rendering

**Purpose:** Convert HTML to PDF.

```python theme={null}
async def render_pdf(
    html_content: str,
    document_id: UUID,
    client_id: UUID,
    agency_id: UUID
) -> PDFResult:
    """
    Render HTML to PDF using WeasyPrint.
    """
    
    from weasyprint import HTML, CSS
    from weasyprint.text.fonts import FontConfiguration
    
    # Configure fonts
    font_config = FontConfiguration()
    
    # Base CSS for PDF rendering
    base_css = CSS(string="""
        @page {
            size: letter;
            margin: 0;
        }
        
        @page :first {
            margin: 0;
        }
        
        body {
            -webkit-print-color-adjust: exact;
            print-color-adjust: exact;
        }
        
        .page-break {
            page-break-after: always;
        }
        
        .avoid-break {
            page-break-inside: avoid;
        }
    """, font_config=font_config)
    
    # Create HTML document
    html_doc = HTML(string=html_content, base_url=settings.STORAGE_BASE_URL)
    
    # Render to PDF
    pdf_bytes = html_doc.write_pdf(
        stylesheets=[base_css],
        font_config=font_config,
        optimize_size=('fonts', 'images')
    )
    
    # Calculate page count
    from PyPDF2 import PdfReader
    pdf_reader = PdfReader(io.BytesIO(pdf_bytes))
    page_count = len(pdf_reader.pages)
    
    # Store PDF
    pdf_path = f"documents/{agency_id}/{client_id}/{document_id}.pdf"
    pdf_url = await storage_service.store_file(
        path=pdf_path,
        data=pdf_bytes,
        content_type="application/pdf"
    )
    
    return PDFResult(
        url=pdf_url,
        path=pdf_path,
        size_bytes=len(pdf_bytes),
        page_count=page_count
    )
```

### 7.2.12 Step 12: Quality Review

**Purpose:** Final validation before completion.

```python theme={null}
async def quality_review(
    pdf_result: PDFResult,
    content: WrittenContent,
    document_id: UUID
) -> QualityReviewResult:
    """
    Perform final quality checks on generated document.
    """
    
    issues = []
    warnings = []
    
    # Check PDF was generated
    if not pdf_result.url:
        issues.append("PDF generation failed")
        return QualityReviewResult(passed=False, issues=issues)
    
    # Check page count is reasonable
    if pdf_result.page_count < 4:
        warnings.append("Document is shorter than expected (< 4 pages)")
    elif pdf_result.page_count > 15:
        warnings.append("Document is longer than expected (> 15 pages)")
    
    # Check file size
    if pdf_result.size_bytes < 100000:  # < 100KB
        warnings.append("PDF file size is unusually small")
    elif pdf_result.size_bytes > 50000000:  # > 50MB
        issues.append("PDF file size exceeds maximum (50MB)")
    
    # Verify content completeness
    if not content.title:
        issues.append("Missing document title")
    
    if not content.sections or len(content.sections) < 3:
        issues.append("Insufficient content sections")
    
    if not content.conclusion:
        warnings.append("Missing conclusion section")
    
    # Check statistics were included
    total_stats = sum(len(s.statistics_used) for s in content.sections)
    if total_stats < 3:
        warnings.append("Few statistics included (< 3)")
    
    passed = len(issues) == 0
    
    return QualityReviewResult(
        passed=passed,
        issues=issues,
        warnings=warnings,
        metrics={
            "page_count": pdf_result.page_count,
            "file_size_bytes": pdf_result.size_bytes,
            "section_count": len(content.sections),
            "statistics_count": total_stats,
            "word_count": calculate_word_count(content)
        }
    )
```

## 7.3 Celery Task Implementation

```python theme={null}
# app/workers/generation_tasks.py

from celery import shared_task, chain
from app.workers.celery_app import celery_app
from app.services.generation import (
    analyze_topic, research_keywords, conduct_web_research,
    analyze_industry, create_outline, write_content,
    integrate_statistics, generate_charts, get_cover_image,
    apply_template, render_pdf, quality_review
)

GENERATION_STEPS = [
    {"id": "topic_analysis", "label": "Analyzing Topic", "weight": 5},
    {"id": "keyword_research", "label": "Researching Keywords", "weight": 5},
    {"id": "web_research", "label": "Conducting Research", "weight": 30},
    {"id": "industry_analysis", "label": "Analyzing Industry", "weight": 10},
    {"id": "outline_creation", "label": "Creating Outline", "weight": 5},
    {"id": "content_writing", "label": "Writing Content", "weight": 25},
    {"id": "statistics_integration", "label": "Integrating Statistics", "weight": 5},
    {"id": "chart_generation", "label": "Generating Charts", "weight": 5},
    {"id": "cover_image", "label": "Preparing Cover Image", "weight": 3},
    {"id": "template_application", "label": "Applying Design", "weight": 4},
    {"id": "pdf_rendering", "label": "Rendering PDF", "weight": 2},
    {"id": "quality_review", "label": "Final Review", "weight": 1},
]


@celery_app.task(bind=True, max_retries=3, default_retry_delay=60)
def generate_document_task(self, document_id: str):
    """
    Main document generation task.
    Orchestrates all generation steps.
    """
    
    try:
        # Load document and related data
        document = load_document(document_id)
        client = load_client(document.client_id)
        agency = load_agency(client.agency_id)
        template = load_template(document.template_id)
        branding = resolve_branding(client, agency)
        
        # Get API keys
        api_keys = get_api_keys(agency)
        
        # Create job record
        job = create_generation_job(document_id, self.request.id)
        
        # Update document status
        update_document_status(document_id, "generating")
        
        # ══════════════════════════════════════════════════════════
        # STEP 1: Topic Analysis
        # ══════════════════════════════════════════════════════════
        update_job_progress(job.id, "topic_analysis", 0)
        
        topic_analysis = analyze_topic(
            topic=document.topic,
            industry=document.input_industry,
            custom_direction=document.input_custom_direction,
            additional_context=document.input_additional_context
        )
        
        update_job_progress(job.id, "topic_analysis", 5, completed=True)
        
        # ══════════════════════════════════════════════════════════
        # STEP 2: Keyword Research
        # ══════════════════════════════════════════════════════════
        update_job_progress(job.id, "keyword_research", 5)
        
        keyword_research = research_keywords(
            topic_analysis=topic_analysis,
            user_keywords=document.input_keywords or []
        )
        
        update_job_progress(job.id, "keyword_research", 10, completed=True)
        
        # ══════════════════════════════════════════════════════════
        # STEP 3: Web Research (MAJOR STEP)
        # ══════════════════════════════════════════════════════════
        update_job_progress(job.id, "web_research", 10, 
                          detail=f"Searching for sources on '{topic_analysis.refined_topic}'...")
        
        research_result = conduct_web_research(
            topic_analysis=topic_analysis,
            keyword_research=keyword_research,
            client=client,
            progress_callback=lambda detail: update_job_detail(job.id, detail)
        )
        
        update_job_progress(job.id, "web_research", 40, completed=True,
                          detail=f"Read {research_result.source_count} sources")
        
        # ══════════════════════════════════════════════════════════
        # STEP 4: Industry Analysis
        # ══════════════════════════════════════════════════════════
        update_job_progress(job.id, "industry_analysis", 40)
        
        industry_analysis = analyze_industry(
            research=research_result,
            topic_analysis=topic_analysis,
            industry=document.input_industry or topic_analysis.industry
        )
        
        update_job_progress(job.id, "industry_analysis", 50, completed=True)
        
        # ══════════════════════════════════════════════════════════
        # STEP 5: Outline Creation
        # ══════════════════════════════════════════════════════════
        update_job_progress(job.id, "outline_creation", 50)
        
        outline = create_outline(
            topic_analysis=topic_analysis,
            research=research_result,
            industry_analysis=industry_analysis,
            client=client
        )
        
        update_job_progress(job.id, "outline_creation", 55, completed=True)
        
        # ══════════════════════════════════════════════════════════
        # STEP 6: Content Writing (MAJOR STEP)
        # ══════════════════════════════════════════════════════════
        update_job_progress(job.id, "content_writing", 55,
                          detail="Writing executive summary...")
        
        written_content = write_content(
            outline=outline,
            research=research_result,
            industry_analysis=industry_analysis,
            topic_analysis=topic_analysis,
            client=client,
            progress_callback=lambda detail: update_job_detail(job.id, detail)
        )
        
        update_job_progress(job.id, "content_writing", 80, completed=True)
        
        # ══════════════════════════════════════════════════════════
        # STEP 7: Statistics Integration
        # ══════════════════════════════════════════════════════════
        update_job_progress(job.id, "statistics_integration", 80)
        
        formatted_statistics = integrate_statistics(
            content=written_content,
            research=research_result
        )
        
        update_job_progress(job.id, "statistics_integration", 85, completed=True)
        
        # ══════════════════════════════════════════════════════════
        # STEP 8: Chart Generation
        # ══════════════════════════════════════════════════════════
        update_job_progress(job.id, "chart_generation", 85)
        
        charts = generate_charts(
            outline=outline,
            statistics=formatted_statistics,
            branding=branding
        )
        
        update_job_progress(job.id, "chart_generation", 90, completed=True)
        
        # ══════════════════════════════════════════════════════════
        # STEP 9: Cover Image
        # ══════════════════════════════════════════════════════════
        update_job_progress(job.id, "cover_image", 90)
        
        cover_image = get_cover_image(
            topic=topic_analysis.refined_topic,
            industry=document.input_industry,
            branding=branding,
            client=client,
            cover_image_search=document.cover_image_search
        )
        
        update_job_progress(job.id, "cover_image", 93, completed=True)
        
        # ══════════════════════════════════════════════════════════
        # STEP 10: Template Application
        # ══════════════════════════════════════════════════════════
        update_job_progress(job.id, "template_application", 93)
        
        html_content = apply_template(
            template=template,
            content=written_content,
            statistics=formatted_statistics,
            charts=charts,
            cover_image=cover_image,
            branding=branding,
            client=client
        )
        
        update_job_progress(job.id, "template_application", 97, completed=True)
        
        # ══════════════════════════════════════════════════════════
        # STEP 11: PDF Rendering
        # ══════════════════════════════════════════════════════════
        update_job_progress(job.id, "pdf_rendering", 97)
        
        pdf_result = render_pdf(
            html_content=html_content,
            document_id=document.id,
            client_id=client.id,
            agency_id=agency.id
        )
        
        update_job_progress(job.id, "pdf_rendering", 99, completed=True)
        
        # ══════════════════════════════════════════════════════════
        # STEP 12: Quality Review
        # ══════════════════════════════════════════════════════════
        update_job_progress(job.id, "quality_review", 99)
        
        quality_result = quality_review(
            pdf_result=pdf_result,
            content=written_content,
            document_id=document.id
        )
        
        if not quality_result.passed:
            raise GenerationError(
                code="QUALITY_FAILED",
                message="Quality review failed",
                details=quality_result.issues
            )
        
        # ══════════════════════════════════════════════════════════
        # COMPLETE
        # ══════════════════════════════════════════════════════════
        
        # Update document with results
        finalize_document(
            document_id=document.id,
            title=written_content.title,
            subtitle=written_content.subtitle,
            content_json=written_content.to_json(),
            pdf_url=pdf_result.url,
            pdf_path=pdf_result.path,
            pdf_size=pdf_result.size_bytes,
            pdf_page_count=pdf_result.page_count,
            cover_image_url=cover_image.url,
            cover_image_source=cover_image.source,
            research_sources=research_result.sources_to_json(),
            research_source_count=research_result.source_count
        )
        
        # Mark job complete
        complete_job(job.id)
        
        # Notify via WebSocket
        publish_completion(document.id, pdf_result.url)
        
        # Log success
        create_audit_log(
            action="generate",
            resource_type="document",
            resource_id=document.id,
            metadata={
                "duration_seconds": job.duration_seconds,
                "page_count": pdf_result.page_count,
                "source_count": research_result.source_count
            }
        )
        
        return {
            "document_id": str(document.id),
            "status": "complete",
            "pdf_url": pdf_result.url
        }
        
    except Exception as e:
        # Handle failure
        logger.exception(f"Generation failed for document {document_id}")
        
        # Update document status
        fail_document(
            document_id=document_id,
            error_code=getattr(e, 'code', 'GEN_UNKNOWN'),
            error_message=str(e),
            error_step=getattr(job, 'current_step', 'unknown')
        )
        
        # Notify via WebSocket
        publish_failure(document_id, str(e))
        
        # Retry if retriable
        if should_retry(e) and self.request.retries < self.max_retries:
            raise self.retry(exc=e)
        
        raise
```

***

# 8. PDF Generation System

## 8.1 Template Architecture

### 8.1.1 Template Directory Structure

```
templates/
├── base/
│   ├── layout.html              # Base page structure
│   ├── styles.css               # Core styles
│   ├── variables.css            # CSS custom properties
│   ├── fonts/
│   │   ├── inter-regular.woff2
│   │   ├── inter-bold.woff2
│   │   ├── playfair-regular.woff2
│   │   └── playfair-bold.woff2
│   └── components/
│       ├── cover.html           # Cover page component
│       ├── header.html          # Page header
│       ├── footer.html          # Page footer
│       ├── section.html         # Content section
│       ├── statistic.html       # Stat callout box
│       ├── chart.html           # Chart container
│       ├── quote.html           # Pull quote
│       ├── callout.html         # Tip/warning/note box
│       └── table.html           # Data table
│
├── executive_01/
│   ├── template.html            # Main template
│   ├── styles.css               # Template-specific styles
│   ├── preview.png              # Preview thumbnail
│   └── sample.pdf               # Sample output
│
├── minimal_02/
│   ├── template.html
│   ├── styles.css
│   ├── preview.png
│   └── sample.pdf
│
└── modern_03/
    ├── template.html
    ├── styles.css
    ├── preview.png
    └── sample.pdf
```

### 8.1.2 Base Template Structure

```html theme={null}
<!-- templates/base/layout.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{{ title }}</title>
    
    <style>
        /* CSS Variables - Injected per client */
        :root {
            /* Colors */
            --color-primary: {{ colors.primary }};
            --color-secondary: {{ colors.secondary }};
            --color-accent: {{ colors.accent }};
            --color-text: {{ colors.text | default('#1a1a1a') }};
            --color-text-light: {{ colors.text_light | default('#6b7280') }};
            --color-background: {{ colors.background | default('#ffffff') }};
            --color-background-alt: {{ colors.background_alt | default('#f8f9fa') }};
            
            /* Derived colors */
            --color-primary-light: {{ colors.primary_light }};
            --color-secondary-light: {{ colors.secondary_light }};
            --color-border: {{ colors.border | default('#e5e7eb') }};
            
            /* Typography */
            --font-heading: 'Playfair Display', Georgia, serif;
            --font-body: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
            
            /* Spacing */
            --spacing-page: 0.75in;
            --spacing-section: 2rem;
            --spacing-paragraph: 1rem;
        }
        
        /* Page setup */
        @page {
            size: letter;
            margin: 0;
        }
        
        @page :first {
            margin: 0;
        }
        
        * {
            box-sizing: border-box;
            margin: 0;
            padding: 0;
        }
        
        body {
            font-family: var(--font-body);
            font-size: 11pt;
            line-height: 1.6;
            color: var(--color-text);
            -webkit-print-color-adjust: exact;
            print-color-adjust: exact;
        }
        
        /* Include base styles */
        {% include 'base/styles.css' %}
    </style>
    
    <!-- Template-specific styles -->
    <style>
        {% block template_styles %}{% endblock %}
    </style>
</head>
<body>
    {% block content %}{% endblock %}
</body>
</html>
```

### 8.1.3 Executive Template Example

```html theme={null}
<!-- templates/executive_01/template.html -->
{% extends 'base/layout.html' %}

{% block template_styles %}
/* Executive Professional Template Styles */

.cover-page {
    height: 11in;
    width: 8.5in;
    position: relative;
    background: linear-gradient(135deg, var(--color-primary) 0%, var(--color-primary-dark) 100%);
    page-break-after: always;
}

.cover-image {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 60%;
    object-fit: cover;
    opacity: 0.3;
}

.cover-content {
    position: absolute;
    bottom: 0;
    left: 0;
    right: 0;
    padding: 2in var(--spacing-page) var(--spacing-page);
    background: linear-gradient(to top, var(--color-primary) 60%, transparent 100%);
}

.cover-title {
    font-family: var(--font-heading);
    font-size: 42pt;
    font-weight: 700;
    color: white;
    line-height: 1.1;
    margin-bottom: 0.5rem;
}

.cover-subtitle {
    font-family: var(--font-body);
    font-size: 16pt;
    font-weight: 400;
    color: rgba(255, 255, 255, 0.9);
    margin-bottom: 2rem;
}

.cover-meta {
    display: flex;
    align-items: center;
    gap: 1rem;
}

.cover-logo {
    height: 40px;
    width: auto;
}

.cover-date {
    font-size: 11pt;
    color: rgba(255, 255, 255, 0.8);
}

/* Content pages */
.content-page {
    padding: var(--spacing-page);
    min-height: 11in;
    position: relative;
}

.page-header {
    position: absolute;
    top: 0.4in;
    left: var(--spacing-page);
    right: var(--spacing-page);
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding-bottom: 0.3in;
    border-bottom: 1px solid var(--color-border);
}

.page-header-logo {
    height: 24px;
}

.page-header-title {
    font-size: 9pt;
    color: var(--color-text-light);
}

.page-content {
    margin-top: 1in;
}

.section-label {
    font-family: var(--font-body);
    font-size: 11pt;
    font-weight: 600;
    color: var(--color-secondary);
    text-transform: uppercase;
    letter-spacing: 0.1em;
    margin-bottom: 0.5rem;
}

.section-title {
    font-family: var(--font-heading);
    font-size: 26pt;
    font-weight: 700;
    color: var(--color-primary);
    line-height: 1.2;
    margin-bottom: 1.5rem;
}

.section-lead {
    font-size: 14pt;
    font-weight: 500;
    color: var(--color-text);
    line-height: 1.5;
    margin-bottom: 1.5rem;
    border-left: 4px solid var(--color-secondary);
    padding-left: 1rem;
}

.section-body p {
    margin-bottom: var(--spacing-paragraph);
}

.section-body p:first-child::first-letter {
    font-family: var(--font-heading);
    font-size: 3.5em;
    float: left;
    line-height: 0.8;
    padding-right: 0.1em;
    color: var(--color-primary);
}

/* Statistics callout */
.stat-callout {
    background: var(--color-primary-light);
    border-left: 4px solid var(--color-primary);
    padding: 1.5rem;
    margin: 1.5rem 0;
    page-break-inside: avoid;
}

.stat-value {
    font-family: var(--font-heading);
    font-size: 36pt;
    font-weight: 700;
    color: var(--color-primary);
    line-height: 1;
}

.stat-label {
    font-size: 12pt;
    color: var(--color-text);
    margin-top: 0.5rem;
}

.stat-source {
    font-size: 9pt;
    color: var(--color-text-light);
    margin-top: 0.5rem;
    font-style: italic;
}

/* Charts */
.chart-container {
    margin: 1.5rem 0;
    page-break-inside: avoid;
}

.chart-title {
    font-size: 12pt;
    font-weight: 600;
    color: var(--color-text);
    margin-bottom: 0.5rem;
}

.chart-image {
    width: 100%;
    height: auto;
}

.chart-source {
    font-size: 9pt;
    color: var(--color-text-light);
    margin-top: 0.5rem;
    text-align: right;
}

/* Pull quotes */
.pull-quote {
    margin: 2rem 0;
    padding: 1.5rem 2rem;
    background: var(--color-background-alt);
    border-radius: 4px;
    page-break-inside: avoid;
}

.pull-quote-text {
    font-family: var(--font-heading);
    font-size: 18pt;
    font-style: italic;
    color: var(--color-primary);
    line-height: 1.4;
}

.pull-quote-attribution {
    margin-top: 1rem;
    font-size: 11pt;
    color: var(--color-text-light);
}

/* Callout boxes */
.callout-box {
    margin: 1.5rem 0;
    padding: 1rem 1.5rem;
    border-radius: 4px;
    page-break-inside: avoid;
}

.callout-box.tip {
    background: #ecfdf5;
    border-left: 4px solid #10b981;
}

.callout-box.warning {
    background: #fffbeb;
    border-left: 4px solid #f59e0b;
}

.callout-box.note {
    background: #eff6ff;
    border-left: 4px solid #3b82f6;
}

.callout-box.example {
    background: var(--color-background-alt);
    border-left: 4px solid var(--color-secondary);
}

.callout-title {
    font-weight: 600;
    font-size: 11pt;
    margin-bottom: 0.5rem;
}

.callout-content {
    font-size: 11pt;
}

/* Page footer */
.page-footer {
    position: absolute;
    bottom: 0.4in;
    left: var(--spacing-page);
    right: var(--spacing-page);
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding-top: 0.3in;
    border-top: 1px solid var(--color-border);
    font-size: 9pt;
    color: var(--color-text-light);
}

.page-number::before {
    content: counter(page);
}

/* Conclusion page */
.conclusion-page {
    background: var(--color-background-alt);
}

.conclusion-title {
    font-family: var(--font-heading);
    font-size: 32pt;
    color: var(--color-primary);
    margin-bottom: 1.5rem;
}

.key-takeaways {
    margin: 2rem 0;
}

.key-takeaway {
    display: flex;
    align-items: flex-start;
    gap: 1rem;
    margin-bottom: 1rem;
}

.key-takeaway-number {
    width: 32px;
    height: 32px;
    background: var(--color-primary);
    color: white;
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
    font-weight: 600;
    flex-shrink: 0;
}

.cta-box {
    background: var(--color-primary);
    color: white;
    padding: 2rem;
    border-radius: 8px;
    margin-top: 2rem;
}

.cta-text {
    font-size: 14pt;
}
{% endblock %}

{% block content %}
<!-- Cover Page -->
<div class="cover-page">
    {% if cover_image and cover_image.url %}
    <img src="{{ cover_image.url }}" class="cover-image" alt="">
    {% endif %}
    
    <div class="cover-content">
        <h1 class="cover-title">{{ title }}</h1>
        {% if subtitle %}
        <p class="cover-subtitle">{{ subtitle }}</p>
        {% endif %}
        
        <div class="cover-meta">
            {% if logos.horizontal %}
            <img src="{{ logos.horizontal }}" class="cover-logo" alt="{{ company_name }}">
            {% endif %}
            <span class="cover-date">{{ generated_date }}</span>
        </div>
    </div>
</div>

<!-- Content Pages -->
{% for section in sections %}
<div class="content-page {% if loop.last %}conclusion-page{% endif %}">
    <header class="page-header">
        {% if logos.horizontal %}
        <img src="{{ logos.horizontal }}" class="page-header-logo" alt="">
        {% endif %}
        <span class="page-header-title">{{ title }}</span>
    </header>
    
    <div class="page-content">
        {% if section.label %}
        <div class="section-label">{{ section.label }}</div>
        {% endif %}
        
        <h2 class="section-title">{{ section.title }}</h2>
        
        {% if section.lead_paragraph %}
        <p class="section-lead">{{ section.lead_paragraph }}</p>
        {% endif %}
        
        <div class="section-body">
            {% for paragraph in section.body_paragraphs %}
            <p>{{ paragraph }}</p>
            {% endfor %}
            
            {% for subsection in section.subsections %}
            <h3 class="subsection-title">{{ subsection.title }}</h3>
            <p>{{ subsection.content }}</p>
            {% endfor %}
        </div>
        
        <!-- Statistics for this section -->
        {% for stat_id in section.statistics_used %}
            {% set stat = get_stat_by_id(statistics, stat_id) %}
            {% if stat %}
            <div class="stat-callout">
                <div class="stat-value">{{ stat.value }}</div>
                <div class="stat-label">{{ stat.label }}</div>
                <div class="stat-source">Source: {{ stat.source }}</div>
            </div>
            {% endif %}
        {% endfor %}
        
        <!-- Chart for this section -->
        {% set chart = get_chart_by_section(charts, loop.index) %}
        {% if chart %}
        <div class="chart-container">
            <div class="chart-title">{{ chart.title }}</div>
            <img src="data:image/png;base64,{{ chart.png_base64 }}" class="chart-image" alt="{{ chart.title }}">
            <div class="chart-source">{{ chart.source }}</div>
        </div>
        {% endif %}
        
        <!-- Callout box -->
        {% if section.callout %}
        <div class="callout-box {{ section.callout.type }}">
            <div class="callout-title">
                {% if section.callout.type == 'tip' %}💡 Pro Tip
                {% elif section.callout.type == 'warning' %}⚠️ Warning
                {% elif section.callout.type == 'note' %}📝 Note
                {% elif section.callout.type == 'example' %}📋 Example
                {% endif %}
            </div>
            <div class="callout-content">{{ section.callout.content }}</div>
        </div>
        {% endif %}
    </div>
    
    <footer class="page-footer">
        <span>{{ footer_text }}</span>
        <span class="page-number"></span>
    </footer>
</div>
{% endfor %}

<!-- Conclusion Page -->
<div class="content-page conclusion-page">
    <header class="page-header">
        {% if logos.horizontal %}
        <img src="{{ logos.horizontal }}" class="page-header-logo" alt="">
        {% endif %}
        <span class="page-header-title">{{ title }}</span>
    </header>
    
    <div class="page-content">
        <h2 class="conclusion-title">{{ conclusion.title }}</h2>
        
        <p class="section-lead">{{ conclusion.lead }}</p>
        
        <div class="section-body">
            <p>{{ conclusion.content }}</p>
        </div>
        
        {% if conclusion.key_takeaways %}
        <div class="key-takeaways">
            <h3>Key Takeaways</h3>
            {% for takeaway in conclusion.key_takeaways %}
            <div class="key-takeaway">
                <div class="key-takeaway-number">{{ loop.index }}</div>
                <div>{{ takeaway }}</div>
            </div>
            {% endfor %}
        </div>
        {% endif %}
        
        {% if conclusion.cta %}
        <div class="cta-box">
            <p class="cta-text">{{ conclusion.cta }}</p>
        </div>
        {% endif %}
    </div>
    
    <footer class="page-footer">
        <span>{{ footer_text }}</span>
        <span class="page-number"></span>
    </footer>
</div>
{% endblock %}
```

## 8.2 Color System for PDFs

### 8.2.1 Color Derivation

```python theme={null}
# app/utils/color_utils.py

from colorsys import rgb_to_hls, hls_to_rgb
from typing import Tuple


def hex_to_rgb(hex_color: str) -> Tuple[int, int, int]:
    """Convert hex color to RGB tuple."""
    hex_color = hex_color.lstrip('#')
    return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))


def rgb_to_hex(rgb: Tuple[int, int, int]) -> str:
    """Convert RGB tuple to hex color."""
    return '#{:02x}{:02x}{:02x}'.format(*rgb)


def lighten_color(hex_color: str, amount: float = 0.9) -> str:
    """
    Lighten a color for backgrounds.
    amount: 0.0 = original, 1.0 = white
    """
    r, g, b = hex_to_rgb(hex_color)
    
    # Convert to 0-1 range
    r, g, b = r/255, g/255, b/255
    
    # Convert to HLS
    h, l, s = rgb_to_hls(r, g, b)
    
    # Increase lightness
    l = l + (1 - l) * amount
    
    # Convert back
    r, g, b = hls_to_rgb(h, l, s)
    
    return rgb_to_hex((int(r*255), int(g*255), int(b*255)))


def darken_color(hex_color: str, amount: float = 0.2) -> str:
    """Darken a color for hover states."""
    r, g, b = hex_to_rgb(hex_color)
    r, g, b = r/255, g/255, b/255
    h, l, s = rgb_to_hls(r, g, b)
    l = l * (1 - amount)
    r, g, b = hls_to_rgb(h, l, s)
    return rgb_to_hex((int(r*255), int(g*255), int(b*255)))


def get_contrast_color(hex_color: str) -> str:
    """Get black or white for best contrast."""
    r, g, b = hex_to_rgb(hex_color)
    # Calculate luminance
    luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255
    return '#ffffff' if luminance < 0.5 else '#000000'


def derive_pdf_colors(
    accent_1: str,
    accent_2: str,
    accent_3: str
) -> dict:
    """
    Derive all PDF colors from the three accent colors.
    Ensures proper contrast and visual hierarchy.
    """
    return {
        # Primary colors
        "primary": accent_1,
        "primary_light": lighten_color(accent_1, 0.9),
        "primary_dark": darken_color(accent_1, 0.2),
        "primary_contrast": get_contrast_color(accent_1),
        
        # Secondary colors
        "secondary": accent_2,
        "secondary_light": lighten_color(accent_2, 0.9),
        "secondary_dark": darken_color(accent_2, 0.2),
        "secondary_contrast": get_contrast_color(accent_2),
        
        # Accent colors
        "accent": accent_3,
        "accent_light": lighten_color(accent_3, 0.9),
        "accent_dark": darken_color(accent_3, 0.2),
        "accent_contrast": get_contrast_color(accent_3),
        
        # Neutral colors (fixed)
        "text": "#1a1a1a",
        "text_light": "#6b7280",
        "background": "#ffffff",
        "background_alt": "#f8f9fa",
        "border": "#e5e7eb",
    }
```

### 8.2.2 Chart Color Schemes

```python theme={null}
def derive_chart_colors(
    accent_1: str,
    accent_2: str,
    accent_3: str
) -> dict:
    """
    Generate chart color palette from brand colors.
    """
    return {
        # Main colors for chart elements
        "primary": accent_1,
        "secondary": accent_2,
        "tertiary": accent_3,
        
        # Extended palette for multi-series charts
        "series": [
            accent_1,
            accent_2,
            accent_3,
            lighten_color(accent_1, 0.3),
            lighten_color(accent_2, 0.3),
            darken_color(accent_1, 0.2),
            darken_color(accent_2, 0.2),
        ],
        
        # Grid and axis colors
        "grid": "#e5e7eb",
        "axis": "#9ca3af",
        "label": "#374151",
    }
```

***

# 9. Distribution System

## 9.1 Distribution Overview

The distribution system handles posting generated content to social media platforms on behalf of clients.

### 9.1.1 Supported Platforms

| Platform        | Post Type      | Content              | Link    | Image       |
| --------------- | -------------- | -------------------- | ------- | ----------- |
| LinkedIn        | Article/Post   | Summary text         | PDF URL | Cover image |
| Facebook        | Page Post      | Summary text         | PDF URL | Cover image |
| Twitter/X       | Tweet + Thread | Summary + key points | PDF URL | Cover image |
| Google Business | Post           | Summary text         | PDF URL | Cover image |

### 9.1.2 Distribution Flow

```
┌─────────────────────────────────────────────────────────────────────────────────┐
│                            DISTRIBUTION FLOW                                     │
└─────────────────────────────────────────────────────────────────────────────────┘

    User Request                   Celery Worker                 Social Platform
         │                              │                              │
         ▼                              │                              │
┌─────────────────┐                     │                              │
│ POST /distribute│                     │                              │
│                 │                     │                              │
│ • Validate OAuth│                     │                              │
│ • Queue task    │                     │                              │
└────────┬────────┘                     │                              │
         │                              │                              │
         │  Celery Task                 │                              │
         └─────────────────────────────►│                              │
                                        │                              │
                                        ▼                              │
                             ┌─────────────────────┐                   │
                             │ Load OAuth Tokens   │                   │
                             │                     │                   │
                             │ • Decrypt tokens    │                   │
                             │ • Refresh if needed │                   │
                             └──────────┬──────────┘                   │
                                        │                              │
                          ┌─────────────┼─────────────┐               │
                          │             │             │               │
                          ▼             ▼             ▼               │
                    ┌──────────┐ ┌──────────┐ ┌──────────┐           │
                    │ LinkedIn │ │ Facebook │ │ Twitter  │           │
                    │ Worker   │ │ Worker   │ │ Worker   │           │
                    └────┬─────┘ └────┬─────┘ └────┬─────┘           │
                         │            │            │                  │
                         │            │            │                  │
                         └────────────┼────────────┘                  │
                                      │                               │
                                      ▼                               │
                           ┌─────────────────────┐                    │
                           │ Generate Social     │                    │
                           │ Content             │                    │
                           │                     │                    │
                           │ • Create summary    │                    │
                           │ • Format for platform                    │
                           │ • Add hashtags      │                    │
                           │ • Include PDF URL   │                    │
                           └──────────┬──────────┘                    │
                                      │                               │
                                      ▼                               │
                           ┌─────────────────────┐                    │
                           │ Upload Media        │───────────────────►│
                           │                     │                    │
                           │ • Upload cover image│   Platform API     │
                           │ • Get media ID      │◄───────────────────│
                           └──────────┬──────────┘                    │
                                      │                               │
                                      ▼                               │
                           ┌─────────────────────┐                    │
                           │ Create Post         │───────────────────►│
                           │                     │                    │
                           │ • Submit post       │   Platform API     │
                           │ • Get post ID/URL   │◄───────────────────│
                           └──────────┬──────────┘                    │
                                      │                               │
                                      ▼                               │
                           ┌─────────────────────┐                    │
                           │ Update Document     │                    │
                           │                     │                    │
                           │ • Save post IDs     │                    │
                           │ • Save post URLs    │                    │
                           │ • Update status     │                    │
                           └─────────────────────┘                    │
```

## 9.2 Platform-Specific Implementations

### 9.2.1 LinkedIn Distribution

```python theme={null}
# app/services/distribution/linkedin_service.py

from typing import Optional
import httpx

class LinkedInDistributionService:
    """Handle LinkedIn content distribution."""
    
    BASE_URL = "https://api.linkedin.com/v2"
    
    async def distribute(
        self,
        document: Document,
        client: Client,
        custom_message: Optional[str] = None
    ) -> DistributionResult:
        """
        Post content to LinkedIn company page or personal profile.
        """
        
        # Get OAuth tokens
        oauth_data = decrypt_oauth(client.oauth_linkedin_encrypted)
        
        # Refresh token if needed
        if is_token_expired(oauth_data):
            oauth_data = await self.refresh_token(oauth_data)
            await update_client_oauth(client.id, "linkedin", oauth_data)
        
        # Generate social content
        social_content = await self.generate_social_content(
            document=document,
            custom_message=custom_message
        )
        
        # Upload cover image if available
        media_id = None
        if document.cover_image_url:
            media_id = await self.upload_image(
                image_url=document.cover_image_url,
                access_token=oauth_data["access_token"],
                owner_id=oauth_data["organization_id"]
            )
        
        # Create post
        post_data = await self.create_post(
            access_token=oauth_data["access_token"],
            organization_id=oauth_data["organization_id"],
            text=social_content.text,
            link_url=document.pdf_url,
            media_id=media_id
        )
        
        return DistributionResult(
            platform="linkedin",
            success=True,
            post_id=post_data["id"],
            post_url=self.construct_post_url(post_data["id"]),
            posted_at=datetime.utcnow()
        )
    
    async def generate_social_content(
        self,
        document: Document,
        custom_message: Optional[str]
    ) -> SocialContent:
        """Generate LinkedIn-optimized content."""
        
        if custom_message:
            # Use custom message with light enhancement
            text = custom_message
        else:
            # Generate from document content
            prompt = f"""Create a LinkedIn post for this thought leadership content.

Title: {document.title}
Topic: {document.topic}
Key Statistics: {format_top_stats(document.content_json.get('statistics', [])[:3])}

Requirements:
- Professional tone
- 150-200 words
- Include 3-5 relevant hashtags
- End with a call-to-action to read the full document
- Do NOT use emojis excessively (1-2 max)

Format:
[Engaging hook]

[Main insight/value proposition]

[Key statistic or finding]

[Call to action]

#Hashtag1 #Hashtag2 #Hashtag3
"""
            
            response = await claude_client.messages.create(
                model="claude-sonnet-4-20250514",
                max_tokens=500,
                messages=[{"role": "user", "content": prompt}]
            )
            
            text = response.content[0].text
        
        return SocialContent(
            text=text,
            hashtags=extract_hashtags(text)
        )
    
    async def upload_image(
        self,
        image_url: str,
        access_token: str,
        owner_id: str
    ) -> str:
        """Upload image to LinkedIn and return media ID."""
        
        # Step 1: Register upload
        register_response = await httpx.post(
            f"{self.BASE_URL}/assets?action=registerUpload",
            headers={"Authorization": f"Bearer {access_token}"},
            json={
                "registerUploadRequest": {
                    "recipes": ["urn:li:digitalmediaRecipe:feedshare-image"],
                    "owner": f"urn:li:organization:{owner_id}",
                    "serviceRelationships": [{
                        "relationshipType": "OWNER",
                        "identifier": "urn:li:userGeneratedContent"
                    }]
                }
            }
        )
        
        upload_url = register_response.json()["value"]["uploadMechanism"][
            "com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest"
        ]["uploadUrl"]
        asset_id = register_response.json()["value"]["asset"]
        
        # Step 2: Download and upload image
        image_data = await httpx.get(image_url)
        
        await httpx.put(
            upload_url,
            content=image_data.content,
            headers={
                "Authorization": f"Bearer {access_token}",
                "Content-Type": "image/jpeg"
            }
        )
        
        return asset_id
    
    async def create_post(
        self,
        access_token: str,
        organization_id: str,
        text: str,
        link_url: str,
        media_id: Optional[str] = None
    ) -> dict:
        """Create LinkedIn post."""
        
        post_body = {
            "author": f"urn:li:organization:{organization_id}",
            "lifecycleState": "PUBLISHED",
            "specificContent": {
                "com.linkedin.ugc.ShareContent": {
                    "shareCommentary": {
                        "text": text
                    },
                    "shareMediaCategory": "ARTICLE" if not media_id else "IMAGE"
                }
            },
            "visibility": {
                "com.linkedin.ugc.MemberNetworkVisibility": "PUBLIC"
            }
        }
        
        if media_id:
            post_body["specificContent"]["com.linkedin.ugc.ShareContent"]["media"] = [{
                "status": "READY",
                "media": media_id,
                "title": {"text": "Read the full document"}
            }]
        
        if link_url:
            post_body["specificContent"]["com.linkedin.ugc.ShareContent"]["media"] = [{
                "status": "READY",
                "originalUrl": link_url
            }]
        
        response = await httpx.post(
            f"{self.BASE_URL}/ugcPosts",
            headers={
                "Authorization": f"Bearer {access_token}",
                "Content-Type": "application/json",
                "X-Restli-Protocol-Version": "2.0.0"
            },
            json=post_body
        )
        
        return response.json()
```

### 9.2.2 Facebook Distribution

```python theme={null}
# app/services/distribution/facebook_service.py

class FacebookDistributionService:
    """Handle Facebook page content distribution."""
    
    GRAPH_URL = "https://graph.facebook.com/v18.0"
    
    async def distribute(
        self,
        document: Document,
        client: Client,
        custom_message: Optional[str] = None
    ) -> DistributionResult:
        """Post content to Facebook page."""
        
        oauth_data = decrypt_oauth(client.oauth_facebook_encrypted)
        
        # Refresh token if needed
        if is_token_expired(oauth_data):
            oauth_data = await self.refresh_token(oauth_data)
        
        page_id = oauth_data["page_id"]
        page_access_token = oauth_data["page_access_token"]
        
        # Generate content
        social_content = await self.generate_social_content(
            document=document,
            custom_message=custom_message
        )
        
        # Create post with link
        post_data = {
            "message": social_content.text,
            "link": document.pdf_url,
            "access_token": page_access_token
        }
        
        response = await httpx.post(
            f"{self.GRAPH_URL}/{page_id}/feed",
            data=post_data
        )
        
        result = response.json()
        
        return DistributionResult(
            platform="facebook",
            success="id" in result,
            post_id=result.get("id"),
            post_url=f"https://facebook.com/{result.get('id')}",
            posted_at=datetime.utcnow(),
            error=result.get("error", {}).get("message")
        )
```

### 9.2.3 Twitter/X Distribution

```python theme={null}
# app/services/distribution/twitter_service.py

class TwitterDistributionService:
    """Handle Twitter/X content distribution."""
    
    API_URL = "https://api.twitter.com/2"
    
    async def distribute(
        self,
        document: Document,
        client: Client,
        custom_message: Optional[str] = None
    ) -> DistributionResult:
        """Post content to Twitter."""
        
        oauth_data = decrypt_oauth(client.oauth_twitter_encrypted)
        
        # Generate content (Twitter has 280 char limit)
        social_content = await self.generate_social_content(
            document=document,
            custom_message=custom_message
        )
        
        # Upload media
        media_id = None
        if document.cover_image_url:
            media_id = await self.upload_media(
                image_url=document.cover_image_url,
                oauth_data=oauth_data
            )
        
        # Create tweet
        tweet_data = {
            "text": social_content.text
        }
        
        if media_id:
            tweet_data["media"] = {"media_ids": [media_id]}
        
        response = await self.authenticated_request(
            "POST",
            f"{self.API_URL}/tweets",
            oauth_data=oauth_data,
            json=tweet_data
        )
        
        result = response.json()
        tweet_id = result["data"]["id"]
        
        return DistributionResult(
            platform="twitter",
            success=True,
            post_id=tweet_id,
            post_url=f"https://twitter.com/i/status/{tweet_id}",
            posted_at=datetime.utcnow()
        )
    
    async def generate_social_content(
        self,
        document: Document,
        custom_message: Optional[str]
    ) -> SocialContent:
        """Generate Twitter-optimized content (280 chars)."""
        
        if custom_message and len(custom_message) <= 280:
            return SocialContent(text=custom_message)
        
        prompt = f"""Create a Twitter post for this content. Must be under 280 characters including hashtags.

Title: {document.title}
Topic: {document.topic}

Requirements:
- Under 280 characters total
- Compelling hook
- 2-3 hashtags
- Include link indicator [link]

Return only the tweet text.
"""
        
        response = await claude_client.messages.create(
            model="claude-sonnet-4-20250514",
            max_tokens=100,
            messages=[{"role": "user", "content": prompt}]
        )
        
        text = response.content[0].text
        
        # Ensure under 280 chars (accounting for link shortening)
        if len(text) > 257:  # 280 - 23 for t.co link
            text = text[:254] + "..."
        
        return SocialContent(text=text)
```

## 9.3 Celery Distribution Task

```python theme={null}
# app/workers/distribution_tasks.py

@celery_app.task(bind=True, max_retries=3, default_retry_delay=300)
def distribute_document_task(
    self,
    document_id: str,
    channels: dict,
    custom_message: Optional[str] = None
) -> dict:
    """
    Distribute document to specified social channels.
    
    Args:
        document_id: Document UUID
        channels: {"linkedin": True, "facebook": True, ...}
        custom_message: Optional custom social post text
    """
    
    try:
        document = load_document(document_id)
        client = load_client(document.client_id)
        
        results = {}
        
        # Process each enabled channel
        if channels.get("linkedin"):
            try:
                result = await linkedin_service.distribute(
                    document=document,
                    client=client,
                    custom_message=custom_message
                )
                results["linkedin"] = result.to_dict()
            except OAuthExpiredError:
                results["linkedin"] = {
                    "success": False,
                    "error_code": "OAUTH_EXPIRED",
                    "error": "LinkedIn access token expired. Please reconnect."
                }
            except Exception as e:
                results["linkedin"] = {
                    "success": False,
                    "error_code": "DIST_ERROR",
                    "error": str(e)
                }
        
        if channels.get("facebook"):
            try:
                result = await facebook_service.distribute(
                    document=document,
                    client=client,
                    custom_message=custom_message
                )
                results["facebook"] = result.to_dict()
            except Exception as e:
                results["facebook"] = {
                    "success": False,
                    "error_code": "DIST_ERROR",
                    "error": str(e)
                }
        
        if channels.get("twitter"):
            try:
                result = await twitter_service.distribute(
                    document=document,
                    client=client,
                    custom_message=custom_message
                )
                results["twitter"] = result.to_dict()
            except Exception as e:
                results["twitter"] = {
                    "success": False,
                    "error_code": "DIST_ERROR",
                    "error": str(e)
                }
        
        if channels.get("google_business"):
            try:
                result = await google_business_service.distribute(
                    document=document,
                    client=client,
                    custom_message=custom_message
                )
                results["google_business"] = result.to_dict()
            except Exception as e:
                results["google_business"] = {
                    "success": False,
                    "error_code": "DIST_ERROR",
                    "error": str(e)
                }
        
        # Update document with results
        any_success = any(r.get("success") for r in results.values())
        
        update_document_distribution(
            document_id=document_id,
            status="distributed" if any_success else document.status,
            distributed_at=datetime.utcnow() if any_success else None,
            distribution_results=results
        )
        
        # Log audit
        create_audit_log(
            action="distribute",
            resource_type="document",
            resource_id=document_id,
            metadata={
                "channels": channels,
                "results": results
            }
        )
        
        return {
            "document_id": document_id,
            "results": results,
            "overall_success": any_success
        }
        
    except Exception as e:
        logger.exception(f"Distribution failed for document {document_id}")
        
        if self.request.retries < self.max_retries:
            raise self.retry(exc=e)
        
        raise
```

***

# 10. File Storage System

## 10.1 Storage Architecture

```python theme={null}
# app/services/storage_service.py

from abc import ABC, abstractmethod
from typing import BinaryIO, Optional
import aiofiles
import boto3
from botocore.config import Config


class StorageBackend(ABC):
    """Abstract base for storage backends."""
    
    @abstractmethod
    async def store(self, path: str, data: bytes, content_type: str) -> str:
        """Store file and return public URL."""
        pass
    
    @abstractmethod
    async def retrieve(self, path: str) -> bytes:
        """Retrieve file contents."""
        pass
    
    @abstractmethod
    async def delete(self, path: str) -> bool:
        """Delete file."""
        pass
    
    @abstractmethod
    async def exists(self, path: str) -> bool:
        """Check if file exists."""
        pass
    
    @abstractmethod
    def get_public_url(self, path: str) -> str:
        """Get public URL for file."""
        pass


class LocalStorageBackend(StorageBackend):
    """Local filesystem storage (development/single-server)."""
    
    def __init__(self, base_path: str, public_url_base: str):
        self.base_path = Path(base_path)
        self.public_url_base = public_url_base.rstrip('/')
        
        # Ensure base directory exists
        self.base_path.mkdir(parents=True, exist_ok=True)
    
    async def store(self, path: str, data: bytes, content_type: str) -> str:
        """Store file to local filesystem."""
        
        full_path = self.base_path / path
        
        # Create parent directories
        full_path.parent.mkdir(parents=True, exist_ok=True)
        
        # Write file
        async with aiofiles.open(full_path, 'wb') as f:
            await f.write(data)
        
        return self.get_public_url(path)
    
    async def retrieve(self, path: str) -> bytes:
        """Read file from local filesystem."""
        
        full_path = self.base_path / path
        
        async with aiofiles.open(full_path, 'rb') as f:
            return await f.read()
    
    async def delete(self, path: str) -> bool:
        """Delete file from local filesystem."""
        
        full_path = self.base_path / path
        
        if full_path.exists():
            full_path.unlink()
            return True
        return False
    
    async def exists(self, path: str) -> bool:
        """Check if file exists."""
        return (self.base_path / path).exists()
    
    def get_public_url(self, path: str) -> str:
        """Get public URL for file."""
        return f"{self.public_url_base}/{path}"


class S3StorageBackend(StorageBackend):
    """AWS S3 or S3-compatible storage."""
    
    def __init__(
        self,
        bucket: str,
        region: str,
        access_key: str,
        secret_key: str,
        endpoint_url: Optional[str] = None,
        public_url_base: Optional[str] = None
    ):
        self.bucket = bucket
        self.public_url_base = public_url_base or f"https://{bucket}.s3.{region}.amazonaws.com"
        
        self.client = boto3.client(
            's3',
            region_name=region,
            aws_access_key_id=access_key,
            aws_secret_access_key=secret_key,
            endpoint_url=endpoint_url,
            config=Config(signature_version='s3v4')
        )
    
    async def store(self, path: str, data: bytes, content_type: str) -> str:
        """Store file to S3."""
        
        self.client.put_object(
            Bucket=self.bucket,
            Key=path,
            Body=data,
            ContentType=content_type,
            ACL='public-read'  # For public documents
        )
        
        return self.get_public_url(path)
    
    async def retrieve(self, path: str) -> bytes:
        """Read file from S3."""
        
        response = self.client.get_object(
            Bucket=self.bucket,
            Key=path
        )
        return response['Body'].read()
    
    async def delete(self, path: str) -> bool:
        """Delete file from S3."""
        
        try:
            self.client.delete_object(
                Bucket=self.bucket,
                Key=path
            )
            return True
        except Exception:
            return False
    
    async def exists(self, path: str) -> bool:
        """Check if file exists in S3."""
        
        try:
            self.client.head_object(
                Bucket=self.bucket,
                Key=path
            )
            return True
        except self.client.exceptions.ClientError:
            return False
    
    def get_public_url(self, path: str) -> str:
        """Get public URL for file."""
        return f"{self.public_url_base}/{path}"


class StorageService:
    """High-level storage service with path management."""
    
    def __init__(self, backend: StorageBackend):
        self.backend = backend
    
    def _build_document_path(
        self,
        agency_id: UUID,
        client_id: UUID,
        document_id: UUID
    ) -> str:
        """Build storage path for document PDF."""
        return f"documents/{agency_id}/{client_id}/{document_id}.pdf"
    
    def _build_cover_path(
        self,
        agency_id: UUID,
        client_id: UUID,
        document_id: UUID
    ) -> str:
        """Build storage path for cover image."""
        return f"covers/{agency_id}/{client_id}/{document_id}.jpg"
    
    def _build_logo_path(
        self,
        agency_id: UUID,
        logo_type: str,
        extension: str
    ) -> str:
        """Build storage path for agency logo."""
        return f"logos/{agency_id}/{logo_type}.{extension}"
    
    async def store_document_pdf(
        self,
        pdf_data: bytes,
        agency_id: UUID,
        client_id: UUID,
        document_id: UUID
    ) -> tuple[str, str]:
        """
        Store document PDF and return (url, path).
        """
        
        path = self._build_document_path(agency_id, client_id, document_id)
        url = await self.backend.store(path, pdf_data, "application/pdf")
        
        return url, path
    
    async def store_cover_image(
        self,
        image_data: bytes,
        agency_id: UUID,
        client_id: UUID,
        document_id: UUID
    ) -> str:
        """Store cover image and return URL."""
        
        path = self._build_cover_path(agency_id, client_id, document_id)
        return await self.backend.store(path, image_data, "image/jpeg")
    
    async def store_logo(
        self,
        image_data: bytes,
        agency_id: UUID,
        logo_type: str,  # horizontal, vertical, round, favicon
        content_type: str
    ) -> str:
        """Store agency logo and return URL."""
        
        extension = content_type.split('/')[-1]
        if extension == "svg+xml":
            extension = "svg"
        
        path = self._build_logo_path(agency_id, logo_type, extension)
        return await self.backend.store(path, image_data, content_type)
    
    async def delete_document_files(
        self,
        agency_id: UUID,
        client_id: UUID,
        document_id: UUID
    ) -> None:
        """Delete all files associated with a document."""
        
        # Delete PDF
        pdf_path = self._build_document_path(agency_id, client_id, document_id)
        await self.backend.delete(pdf_path)
        
        # Delete cover image
        cover_path = self._build_cover_path(agency_id, client_id, document_id)
        await self.backend.delete(cover_path)


# Factory function
def create_storage_service(settings: Settings) -> StorageService:
    """Create storage service based on configuration."""
    
    if settings.STORAGE_PROVIDER == "local":
        backend = LocalStorageBackend(
            base_path=settings.STORAGE_LOCAL_PATH,
            public_url_base=settings.STORAGE_PUBLIC_URL
        )
    elif settings.STORAGE_PROVIDER == "s3":
        backend = S3StorageBackend(
            bucket=settings.STORAGE_S3_BUCKET,
            region=settings.STORAGE_S3_REGION,
            access_key=settings.STORAGE_S3_ACCESS_KEY,
            secret_key=settings.STORAGE_S3_SECRET_KEY,
            endpoint_url=settings.STORAGE_S3_ENDPOINT,
            public_url_base=settings.STORAGE_PUBLIC_URL
        )
    else:
        raise ValueError(f"Unknown storage provider: {settings.STORAGE_PROVIDER}")
    
    return StorageService(backend)
```

## 10.2 Public URL Configuration

For unbranded document hosting:

```python theme={null}
# URL patterns for documents

# Default (unbranded)
# https://authapi.net/files/{agency_slug}/{client_slug}/{document_id}.pdf
# https://sec-admn.com/files/{agency_slug}/{client_slug}/{document_id}.pdf

# Custom domain (Enterprise)
# https://{custom_domain}/files/{client_slug}/{document_id}.pdf

def get_document_public_url(
    document: Document,
    client: Client,
    agency: Agency
) -> str:
    """Generate public URL for document access."""
    
    if agency.custom_domain and agency.custom_domain_verified:
        base = f"https://{agency.custom_domain}"
    else:
        base = settings.DEFAULT_FILE_URL_BASE  # https://authapi.net
    
    return f"{base}/files/{agency.slug}/{client.company_slug}/{document.id}.pdf"
```

***

# 11. White-Label System

## 11.1 Domain Routing Architecture

The white-label system allows agencies to present Content Strategist under their own branding with custom domains.

### 11.1.1 Domain Types

| Type                | Example                      | Plan       | Configuration       |
| ------------------- | ---------------------------- | ---------- | ------------------- |
| Default Subdomain   | `acme.contentstrategist.com` | All        | Automatic from slug |
| Unbranded File Host | `authapi.net/files/acme/...` | All        | System default      |
| Custom Domain       | `content.acmeagency.com`     | Enterprise | DNS + SSL setup     |

### 11.1.2 Domain Resolution Middleware

```python theme={null}
# app/middleware/agency_resolver.py

from fastapi import Request, HTTPException
from starlette.middleware.base import BaseHTTPMiddleware
from typing import Optional
import re


class AgencyResolverMiddleware(BaseHTTPMiddleware):
    """
    Resolve agency from request domain/subdomain.
    Attaches agency to request.state for use in routes.
    """
    
    # Patterns for domain matching
    SUBDOMAIN_PATTERN = re.compile(r'^([a-z0-9-]+)\.contentstrategist\.com$')
    
    # Routes that don't require agency resolution
    PUBLIC_ROUTES = [
        '/api/v1/auth/login',
        '/api/v1/auth/password/forgot',
        '/api/v1/demo/',
        '/health',
        '/docs',
        '/openapi.json',
    ]
    
    ADMIN_ROUTES = [
        '/api/v1/admin/',
    ]
    
    async def dispatch(self, request: Request, call_next):
        # Skip resolution for public routes
        path = request.url.path
        if any(path.startswith(route) for route in self.PUBLIC_ROUTES):
            request.state.agency = None
            return await call_next(request)
        
        # Admin routes don't need agency
        if any(path.startswith(route) for route in self.ADMIN_ROUTES):
            request.state.agency = None
            request.state.is_admin_route = True
            return await call_next(request)
        
        # Get host header
        host = request.headers.get('host', '').lower()
        
        # Remove port if present
        host = host.split(':')[0]
        
        # Try to resolve agency
        agency = await self._resolve_agency(host)
        
        if agency is None:
            # Check if this is a known domain pattern
            if self._is_platform_domain(host):
                raise HTTPException(
                    status_code=404,
                    detail={
                        "error": "agency_not_found",
                        "message": "Agency not found for this domain"
                    }
                )
            # Unknown domain - might be direct API access
            request.state.agency = None
        else:
            # Verify agency is active
            if not agency.is_active:
                raise HTTPException(
                    status_code=403,
                    detail={
                        "error": "agency_inactive",
                        "message": "This agency account is no longer active"
                    }
                )
            
            # Verify subscription is valid
            if agency.subscription_status in ['canceled', 'suspended']:
                raise HTTPException(
                    status_code=403,
                    detail={
                        "error": "subscription_invalid",
                        "message": f"Agency subscription is {agency.subscription_status}"
                    }
                )
            
            request.state.agency = agency
        
        return await call_next(request)
    
    async def _resolve_agency(self, host: str) -> Optional[Agency]:
        """
        Resolve agency from hostname.
        
        Priority:
        1. Custom domain (exact match)
        2. Subdomain (pattern match)
        """
        
        # Check custom domain first
        agency = await get_agency_by_custom_domain(host)
        if agency:
            return agency
        
        # Check subdomain pattern
        match = self.SUBDOMAIN_PATTERN.match(host)
        if match:
            slug = match.group(1)
            return await get_agency_by_slug(slug)
        
        return None
    
    def _is_platform_domain(self, host: str) -> bool:
        """Check if host is a platform domain (not external)."""
        platform_domains = [
            'contentstrategist.com',
            'authapi.net',
            'sec-admn.com',
        ]
        return any(host.endswith(d) for d in platform_domains)


# Database queries for resolution
async def get_agency_by_custom_domain(domain: str) -> Optional[Agency]:
    """Look up agency by verified custom domain."""
    async with get_db_session() as db:
        result = await db.execute(
            select(Agency)
            .where(Agency.custom_domain == domain)
            .where(Agency.custom_domain_verified == True)
            .where(Agency.is_active == True)
        )
        return result.scalar_one_or_none()


async def get_agency_by_slug(slug: str) -> Optional[Agency]:
    """Look up agency by slug."""
    async with get_db_session() as db:
        result = await db.execute(
            select(Agency)
            .where(Agency.slug == slug)
            .where(Agency.is_active == True)
        )
        return result.scalar_one_or_none()
```

### 11.1.3 Branding Resolution

```python theme={null}
# app/services/branding_service.py

from dataclasses import dataclass
from typing import Optional


@dataclass
class ResolvedBranding:
    """Complete branding configuration for rendering."""
    
    # Colors
    color_mode: str  # 'light' or 'dark'
    color_primary: str
    color_secondary: str
    color_accent: str
    color_text: str
    color_background: str
    
    # Derived colors
    color_primary_light: str
    color_secondary_light: str
    
    # Logos
    logo_horizontal_url: Optional[str]
    logo_vertical_url: Optional[str]
    logo_round_url: Optional[str]
    
    # Footer
    footer_text: str
    
    # Company info
    company_name: str
    company_website: Optional[str]


def resolve_branding(client: Client, agency: Agency) -> ResolvedBranding:
    """
    Resolve branding with client -> agency fallback.
    
    Client-level settings override agency defaults.
    """
    
    # Colors: client overrides agency
    color_primary = client.color_accent_1 or agency.color_accent_1
    color_secondary = client.color_accent_2 or agency.color_accent_2
    color_accent = client.color_accent_3 or agency.color_accent_3
    
    # Logos: client overrides agency
    logo_horizontal = client.logo_horizontal_url or agency.logo_horizontal_url
    logo_vertical = client.logo_vertical_url or agency.logo_vertical_url
    logo_round = client.logo_round_url or agency.logo_round_url
    
    # Footer: client overrides agency
    footer_text = (
        client.footer_text or 
        agency.footer_text or 
        f"© {datetime.now().year} {client.company_name}"
    )
    
    # Derive additional colors
    colors = derive_pdf_colors(color_primary, color_secondary, color_accent)
    
    return ResolvedBranding(
        color_mode=agency.color_mode,
        color_primary=color_primary,
        color_secondary=color_secondary,
        color_accent=color_accent,
        color_text=colors['text'],
        color_background=colors['background'],
        color_primary_light=colors['primary_light'],
        color_secondary_light=colors['secondary_light'],
        logo_horizontal_url=logo_horizontal,
        logo_vertical_url=logo_vertical,
        logo_round_url=logo_round,
        footer_text=footer_text,
        company_name=client.company_name,
        company_website=client.website_url
    )
```

### 11.1.4 Custom Domain Setup

```python theme={null}
# app/services/domain_service.py

import secrets
import dns.resolver
from typing import Tuple


class DomainService:
    """Handle custom domain configuration and verification."""
    
    VERIFICATION_PREFIX = "_content-strategist-verify"
    
    async def initiate_domain_setup(
        self,
        agency_id: UUID,
        domain: str
    ) -> dict:
        """
        Start custom domain setup process.
        Returns verification instructions.
        """
        
        # Validate domain format
        if not self._is_valid_domain(domain):
            raise ValueError("Invalid domain format")
        
        # Check domain isn't already in use
        existing = await get_agency_by_custom_domain(domain)
        if existing and existing.id != agency_id:
            raise ValueError("Domain is already in use by another agency")
        
        # Generate verification token
        token = secrets.token_urlsafe(32)
        
        # Store pending verification
        await update_agency(
            agency_id=agency_id,
            custom_domain=domain,
            custom_domain_verified=False,
            custom_domain_verification_token=token
        )
        
        return {
            "domain": domain,
            "verification_method": "dns_txt",
            "verification_record": {
                "type": "TXT",
                "name": f"{self.VERIFICATION_PREFIX}.{domain}",
                "value": f"content-strategist-verify={token}"
            },
            "instructions": (
                f"Add a TXT record to your DNS configuration:\n"
                f"Name: {self.VERIFICATION_PREFIX}.{domain}\n"
                f"Value: content-strategist-verify={token}\n"
                f"Then call the verify endpoint."
            )
        }
    
    async def verify_domain(self, agency_id: UUID) -> Tuple[bool, str]:
        """
        Verify domain ownership via DNS TXT record.
        Returns (success, message).
        """
        
        agency = await get_agency(agency_id)
        
        if not agency.custom_domain:
            return False, "No custom domain configured"
        
        if not agency.custom_domain_verification_token:
            return False, "No verification token found"
        
        # Query DNS for TXT record
        try:
            record_name = f"{self.VERIFICATION_PREFIX}.{agency.custom_domain}"
            answers = dns.resolver.resolve(record_name, 'TXT')
            
            expected_value = f"content-strategist-verify={agency.custom_domain_verification_token}"
            
            for rdata in answers:
                txt_value = rdata.to_text().strip('"')
                if txt_value == expected_value:
                    # Verification successful
                    await update_agency(
                        agency_id=agency_id,
                        custom_domain_verified=True,
                        custom_domain_verified_at=datetime.utcnow()
                    )
                    
                    # Trigger SSL provisioning
                    await self._provision_ssl(agency.custom_domain)
                    
                    return True, "Domain verified successfully"
            
            return False, "Verification record not found or incorrect"
            
        except dns.resolver.NXDOMAIN:
            return False, "DNS record not found"
        except dns.resolver.NoAnswer:
            return False, "No TXT record found"
        except Exception as e:
            return False, f"DNS lookup failed: {str(e)}"
    
    async def _provision_ssl(self, domain: str):
        """Trigger SSL certificate provisioning via Let's Encrypt."""
        # Implementation depends on deployment setup
        # For Dokploy/Traefik, this is typically automatic
        pass
    
    def _is_valid_domain(self, domain: str) -> bool:
        """Validate domain format."""
        import re
        pattern = r'^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$'
        return bool(re.match(pattern, domain.lower()))
```

***

# 12. CSV Import System

## 12.1 CSV Schema Definition

```python theme={null}
# app/schemas/schedule_import.py

from pydantic import BaseModel, Field, validator
from typing import List, Optional
from datetime import date, time


# Required CSV columns
REQUIRED_COLUMNS = ['scheduled_date', 'topic']

# Optional CSV columns
OPTIONAL_COLUMNS = [
    'scheduled_time',
    'template_code',
    'tone',
    'keywords',
    'related_services',
    'custom_direction',
    'additional_context',
    'auto_distribute',
    'linkedin',
    'facebook',
    'twitter',
    'google_business'
]

# Example CSV header
EXAMPLE_CSV_HEADER = (
    "scheduled_date,scheduled_time,topic,template_code,tone,"
    "keywords,related_services,custom_direction,auto_distribute,"
    "linkedin,facebook,twitter,google_business"
)


class CSVRowData(BaseModel):
    """Validated row from CSV import."""
    
    scheduled_date: date
    scheduled_time: time = Field(default=time(9, 0, 0))
    topic: str = Field(..., min_length=3, max_length=500)
    template_code: Optional[str] = None
    tone: str = Field(default='professional')
    keywords: List[str] = Field(default_factory=list)
    related_services: List[str] = Field(default_factory=list)
    custom_direction: Optional[str] = None
    additional_context: Optional[str] = None
    auto_distribute: bool = False
    distribution_channels: dict = Field(default_factory=dict)
    
    @validator('scheduled_date')
    def date_not_in_past(cls, v):
        if v < date.today():
            raise ValueError('Scheduled date cannot be in the past')
        return v
    
    @validator('tone')
    def valid_tone(cls, v):
        valid_tones = ['professional', 'casual', 'authoritative']
        if v.lower() not in valid_tones:
            raise ValueError(f'Tone must be one of: {valid_tones}')
        return v.lower()
    
    @validator('keywords', 'related_services', pre=True)
    def parse_list(cls, v):
        if isinstance(v, str):
            return [x.strip() for x in v.split(',') if x.strip()]
        return v


class ImportResult(BaseModel):
    """Result of CSV import operation."""
    
    dry_run: bool
    total_rows: int
    valid_rows: int
    invalid_rows: int
    imported_count: int = 0
    
    errors: List[dict]  # [{"row": 1, "field": "topic", "message": "..."}]
    warnings: List[dict]
    
    import_batch_id: Optional[str] = None
    first_scheduled_at: Optional[str] = None
    last_scheduled_at: Optional[str] = None
```

## 12.2 CSV Import Service

```python theme={null}
# app/services/csv_import_service.py

import csv
import io
from typing import List, Tuple
from uuid import uuid4


class CSVImportService:
    """Handle CSV import for scheduled content."""
    
    async def import_schedule(
        self,
        client_id: UUID,
        file_content: bytes,
        default_timezone: str = 'America/New_York',
        dry_run: bool = False
    ) -> ImportResult:
        """
        Import scheduled content from CSV file.
        
        Args:
            client_id: Target client ID
            file_content: Raw CSV file bytes
            default_timezone: Default timezone for rows without timezone
            dry_run: If True, validate only without creating records
        """
        
        # Parse CSV
        rows, parse_errors = self._parse_csv(file_content)
        
        if parse_errors:
            return ImportResult(
                dry_run=dry_run,
                total_rows=0,
                valid_rows=0,
                invalid_rows=len(parse_errors),
                errors=parse_errors,
                warnings=[]
            )
        
        # Load agency and client for validation
        client = await get_client(client_id)
        agency = await get_agency(client.agency_id)
        available_templates = await get_agency_templates(agency.id)
        template_codes = {t.code for t in available_templates}
        
        # Validate each row
        valid_rows = []
        errors = []
        warnings = []
        
        for i, row in enumerate(rows, start=2):  # Start at 2 (header is row 1)
            row_errors, row_warnings, validated = await self._validate_row(
                row=row,
                row_number=i,
                template_codes=template_codes,
                default_timezone=default_timezone
            )
            
            errors.extend(row_errors)
            warnings.extend(row_warnings)
            
            if not row_errors:
                valid_rows.append((i, validated))
        
        # If dry run, return validation results
        if dry_run:
            return ImportResult(
                dry_run=True,
                total_rows=len(rows),
                valid_rows=len(valid_rows),
                invalid_rows=len(rows) - len(valid_rows),
                errors=errors,
                warnings=warnings
            )
        
        # Import valid rows
        if not valid_rows:
            return ImportResult(
                dry_run=False,
                total_rows=len(rows),
                valid_rows=0,
                invalid_rows=len(rows),
                errors=errors,
                warnings=warnings
            )
        
        # Create batch ID
        batch_id = str(uuid4())
        
        # Insert records
        scheduled_items = []
        for row_number, data in valid_rows:
            item = await create_scheduled_content(
                client_id=client_id,
                scheduled_date=data.scheduled_date,
                scheduled_time=data.scheduled_time,
                timezone=default_timezone,
                topic=data.topic,
                template_code=data.template_code,
                tone=data.tone,
                keywords=data.keywords,
                related_services=data.related_services,
                custom_direction=data.custom_direction,
                additional_context=data.additional_context,
                auto_distribute=data.auto_distribute,
                distribution_channels=data.distribution_channels,
                import_batch_id=batch_id,
                import_row_number=row_number
            )
            scheduled_items.append(item)
        
        # Calculate date range
        scheduled_dates = [item.scheduled_at for item in scheduled_items]
        
        return ImportResult(
            dry_run=False,
            total_rows=len(rows),
            valid_rows=len(valid_rows),
            invalid_rows=len(rows) - len(valid_rows),
            imported_count=len(scheduled_items),
            errors=errors,
            warnings=warnings,
            import_batch_id=batch_id,
            first_scheduled_at=min(scheduled_dates).isoformat() if scheduled_dates else None,
            last_scheduled_at=max(scheduled_dates).isoformat() if scheduled_dates else None
        )
    
    def _parse_csv(self, file_content: bytes) -> Tuple[List[dict], List[dict]]:
        """Parse CSV content into list of row dictionaries."""
        
        errors = []
        
        try:
            # Decode with UTF-8 handling
            content = file_content.decode('utf-8-sig')  # Handle BOM
        except UnicodeDecodeError:
            try:
                content = file_content.decode('latin-1')
            except Exception as e:
                return [], [{"row": 0, "field": "file", "message": f"Unable to decode file: {e}"}]
        
        # Parse CSV
        try:
            reader = csv.DictReader(io.StringIO(content))
            rows = list(reader)
        except csv.Error as e:
            return [], [{"row": 0, "field": "file", "message": f"CSV parsing error: {e}"}]
        
        # Validate required columns
        if rows:
            headers = set(rows[0].keys())
            missing = set(REQUIRED_COLUMNS) - headers
            if missing:
                return [], [{
                    "row": 1,
                    "field": "headers",
                    "message": f"Missing required columns: {', '.join(missing)}"
                }]
        
        return rows, errors
    
    async def _validate_row(
        self,
        row: dict,
        row_number: int,
        template_codes: set,
        default_timezone: str
    ) -> Tuple[List[dict], List[dict], Optional[CSVRowData]]:
        """Validate a single CSV row."""
        
        errors = []
        warnings = []
        
        # Parse scheduled_date
        try:
            scheduled_date = self._parse_date(row.get('scheduled_date', ''))
            if scheduled_date < date.today():
                errors.append({
                    "row": row_number,
                    "field": "scheduled_date",
                    "message": f"Date '{row.get('scheduled_date')}' is in the past"
                })
        except ValueError as e:
            errors.append({
                "row": row_number,
                "field": "scheduled_date",
                "message": str(e)
            })
            scheduled_date = None
        
        # Parse scheduled_time
        try:
            scheduled_time = self._parse_time(row.get('scheduled_time', '09:00'))
        except ValueError:
            scheduled_time = time(9, 0, 0)
            warnings.append({
                "row": row_number,
                "field": "scheduled_time",
                "message": "Invalid time format, using default 09:00"
            })
        
        # Validate topic
        topic = row.get('topic', '').strip()
        if not topic:
            errors.append({
                "row": row_number,
                "field": "topic",
                "message": "Topic is required"
            })
        elif len(topic) < 3:
            errors.append({
                "row": row_number,
                "field": "topic",
                "message": "Topic must be at least 3 characters"
            })
        
        # Validate template_code
        template_code = row.get('template_code', '').strip() or None
        if template_code and template_code not in template_codes:
            errors.append({
                "row": row_number,
                "field": "template_code",
                "message": f"Template '{template_code}' not found or not available"
            })
        
        # Validate tone
        tone = row.get('tone', 'professional').strip().lower()
        if tone not in ['professional', 'casual', 'authoritative']:
            tone = 'professional'
            warnings.append({
                "row": row_number,
                "field": "tone",
                "message": f"Invalid tone '{row.get('tone')}', using 'professional'"
            })
        
        # Parse list fields
        keywords = self._parse_list(row.get('keywords', ''))
        related_services = self._parse_list(row.get('related_services', ''))
        
        if not keywords:
            warnings.append({
                "row": row_number,
                "field": "keywords",
                "message": "No keywords provided, AI will determine keywords"
            })
        
        # Parse boolean fields
        auto_distribute = self._parse_bool(row.get('auto_distribute', 'false'))
        
        # Parse distribution channels
        distribution_channels = {
            'linkedin': self._parse_bool(row.get('linkedin', 'false')),
            'facebook': self._parse_bool(row.get('facebook', 'false')),
            'twitter': self._parse_bool(row.get('twitter', 'false')),
            'google_business': self._parse_bool(row.get('google_business', 'false'))
        }
        
        # If errors, return None for validated data
        if errors:
            return errors, warnings, None
        
        # Create validated data object
        validated = CSVRowData(
            scheduled_date=scheduled_date,
            scheduled_time=scheduled_time,
            topic=topic,
            template_code=template_code,
            tone=tone,
            keywords=keywords,
            related_services=related_services,
            custom_direction=row.get('custom_direction', '').strip() or None,
            additional_context=row.get('additional_context', '').strip() or None,
            auto_distribute=auto_distribute,
            distribution_channels=distribution_channels
        )
        
        return errors, warnings, validated
    
    def _parse_date(self, value: str) -> date:
        """Parse date from various formats."""
        from dateutil import parser
        if not value:
            raise ValueError("Date is required")
        try:
            return parser.parse(value).date()
        except Exception:
            raise ValueError(f"Invalid date format: {value}")
    
    def _parse_time(self, value: str) -> time:
        """Parse time from various formats."""
        from dateutil import parser
        if not value:
            return time(9, 0, 0)
        try:
            return parser.parse(value).time()
        except Exception:
            raise ValueError(f"Invalid time format: {value}")
    
    def _parse_list(self, value: str) -> List[str]:
        """Parse comma-separated list."""
        if not value:
            return []
        return [x.strip() for x in value.split(',') if x.strip()]
    
    def _parse_bool(self, value: str) -> bool:
        """Parse boolean from string."""
        return value.lower() in ['true', '1', 'yes', 'y']
```

***

# 13. Scheduled Tasks

## 13.1 Celery Beat Configuration

```python theme={null}
# app/workers/celery_app.py

from celery import Celery
from celery.schedules import crontab
from app.config import settings

celery_app = Celery(
    'content_strategist',
    broker=settings.REDIS_URL,
    backend=settings.REDIS_URL,
    include=[
        'app.workers.generation_tasks',
        'app.workers.distribution_tasks',
        'app.workers.scheduled_tasks',
        'app.workers.maintenance_tasks',
    ]
)

# Celery configuration
celery_app.conf.update(
    task_serializer='json',
    accept_content=['json'],
    result_serializer='json',
    timezone='UTC',
    enable_utc=True,
    
    # Task routing
    task_routes={
        'app.workers.generation_tasks.*': {'queue': 'generation'},
        'app.workers.distribution_tasks.*': {'queue': 'distribution'},
        'app.workers.scheduled_tasks.*': {'queue': 'scheduled'},
        'app.workers.maintenance_tasks.*': {'queue': 'maintenance'},
    },
    
    # Task settings
    task_acks_late=True,
    task_reject_on_worker_lost=True,
    worker_prefetch_multiplier=1,
    
    # Result settings
    result_expires=86400,  # 24 hours
)

# Celery Beat schedule
celery_app.conf.beat_schedule = {
    
    # Process scheduled content every 5 minutes
    'process-scheduled-content': {
        'task': 'app.workers.scheduled_tasks.process_scheduled_content',
        'schedule': crontab(minute='*/5'),
        'options': {'queue': 'scheduled'}
    },
    
    # Clean up expired documents daily at 3 AM
    'cleanup-expired-documents': {
        'task': 'app.workers.maintenance_tasks.cleanup_expired_documents',
        'schedule': crontab(hour=3, minute=0),
        'options': {'queue': 'maintenance'}
    },
    
    # Reset API usage counters on billing period
    'reset-api-usage': {
        'task': 'app.workers.maintenance_tasks.reset_api_usage_counters',
        'schedule': crontab(hour=0, minute=0),  # Daily check
        'options': {'queue': 'maintenance'}
    },
    
    # Send API usage warnings at 80%
    'check-api-usage-warnings': {
        'task': 'app.workers.maintenance_tasks.check_api_usage_warnings',
        'schedule': crontab(hour='*/4'),  # Every 4 hours
        'options': {'queue': 'maintenance'}
    },
    
    # Cleanup old generation jobs weekly
    'cleanup-old-jobs': {
        'task': 'app.workers.maintenance_tasks.cleanup_old_generation_jobs',
        'schedule': crontab(hour=4, minute=0, day_of_week=0),  # Sunday 4 AM
        'options': {'queue': 'maintenance'}
    },
    
    # Refresh OAuth tokens that are expiring
    'refresh-expiring-oauth': {
        'task': 'app.workers.maintenance_tasks.refresh_expiring_oauth_tokens',
        'schedule': crontab(hour='*/6'),  # Every 6 hours
        'options': {'queue': 'maintenance'}
    },
}
```

## 13.2 Scheduled Content Processor

```python theme={null}
# app/workers/scheduled_tasks.py

from datetime import datetime, timedelta
from celery import shared_task
from sqlalchemy import select, and_


@shared_task(bind=True, max_retries=3)
def process_scheduled_content(self):
    """
    Process scheduled content that is due.
    Runs every 5 minutes via Celery Beat.
    """
    
    now = datetime.utcnow()
    
    # Find pending items that are due
    # (scheduled_at <= now + 5 minutes buffer)
    due_items = get_due_scheduled_content(
        before=now + timedelta(minutes=5)
    )
    
    processed = 0
    failed = 0
    
    for item in due_items:
        try:
            # Mark as processing
            update_scheduled_content_status(item.id, 'processing')
            
            # Trigger document generation
            from app.workers.generation_tasks import generate_document_task
            
            # Create document record
            document = create_document_from_schedule(item)
            
            # Queue generation task
            generate_document_task.delay(str(document.id))
            
            # Update scheduled item with document reference
            update_scheduled_content(
                item.id,
                status='completed',
                document_id=document.id,
                processed_at=datetime.utcnow()
            )
            
            processed += 1
            
        except Exception as e:
            logger.exception(f"Failed to process scheduled content {item.id}")
            
            update_scheduled_content(
                item.id,
                status='failed',
                error_message=str(e),
                processed_at=datetime.utcnow()
            )
            
            failed += 1
    
    return {
        'processed': processed,
        'failed': failed,
        'total_due': len(due_items)
    }


def get_due_scheduled_content(before: datetime) -> List[ScheduledContent]:
    """Get all pending scheduled content that is due."""
    
    with get_db_session() as db:
        result = db.execute(
            select(ScheduledContent)
            .where(and_(
                ScheduledContent.status == 'pending',
                ScheduledContent.scheduled_at <= before
            ))
            .order_by(ScheduledContent.scheduled_at.asc())
            .limit(100)  # Process in batches
        )
        return result.scalars().all()


def create_document_from_schedule(item: ScheduledContent) -> Document:
    """Create a document record from scheduled content."""
    
    client = get_client(item.client_id)
    
    # Get template
    template = None
    if item.template_code:
        template = get_template_by_code(item.template_code)
    
    document = Document(
        client_id=item.client_id,
        template_id=template.id if template else None,
        scheduled_content_id=item.id,
        topic=item.topic,
        title=item.topic,  # Will be updated by generation
        status='pending',
        input_tone=item.tone,
        input_keywords=item.keywords,
        input_related_services=item.related_services,
        input_custom_direction=item.custom_direction,
        input_additional_context=item.additional_context,
        input_industry=client.industry,
        generation_queued_at=datetime.utcnow()
    )
    
    with get_db_session() as db:
        db.add(document)
        db.commit()
        db.refresh(document)
    
    return document
```

***

# 14. WebSocket Real-Time Updates

## 14.1 WebSocket Handler

```python theme={null}
# app/api/websocket.py

from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends
from typing import Dict, Set
import asyncio
import json

router = APIRouter()

# Store active connections by job_id
connections: Dict[str, Set[WebSocket]] = {}


class ConnectionManager:
    """Manage WebSocket connections."""
    
    def __init__(self):
        self.active_connections: Dict[str, Set[WebSocket]] = {}
    
    async def connect(self, websocket: WebSocket, job_id: str):
        """Accept and store connection."""
        await websocket.accept()
        
        if job_id not in self.active_connections:
            self.active_connections[job_id] = set()
        
        self.active_connections[job_id].add(websocket)
    
    def disconnect(self, websocket: WebSocket, job_id: str):
        """Remove connection."""
        if job_id in self.active_connections:
            self.active_connections[job_id].discard(websocket)
            
            if not self.active_connections[job_id]:
                del self.active_connections[job_id]
    
    async def broadcast_to_job(self, job_id: str, message: dict):
        """Send message to all connections for a job."""
        if job_id not in self.active_connections:
            return
        
        disconnected = set()
        
        for websocket in self.active_connections[job_id]:
            try:
                await websocket.send_json(message)
            except Exception:
                disconnected.add(websocket)
        
        # Clean up disconnected
        for ws in disconnected:
            self.active_connections[job_id].discard(ws)


manager = ConnectionManager()


@router.websocket("/ws/generation/{job_id}")
async def generation_websocket(
    websocket: WebSocket,
    job_id: str
):
    """
    WebSocket endpoint for real-time generation updates.
    
    Client connects and receives:
    - Progress updates
    - Step completions
    - Final result or error
    """
    
    # Validate job exists and user has access
    # (In production, extract token from query param and validate)
    
    await manager.connect(websocket, job_id)
    
    try:
        # Subscribe to Redis pub/sub for this job
        pubsub = redis_client.pubsub()
        await pubsub.subscribe(f"generation:{job_id}")
        
        # Send current state
        current_state = await get_job_state(job_id)
        if current_state:
            await websocket.send_json(current_state)
        
        # Listen for updates
        while True:
            # Check for messages from Redis
            message = await pubsub.get_message(ignore_subscribe_messages=True, timeout=1.0)
            
            if message and message['type'] == 'message':
                data = json.loads(message['data'])
                await websocket.send_json(data)
                
                # If complete or error, close connection
                if data.get('type') in ['complete', 'error']:
                    break
            
            # Also listen for client messages (ping/pong, unsubscribe)
            try:
                client_message = await asyncio.wait_for(
                    websocket.receive_text(),
                    timeout=0.1
                )
                
                if client_message == 'ping':
                    await websocket.send_text('pong')
                elif client_message == 'unsubscribe':
                    break
                    
            except asyncio.TimeoutError:
                pass
    
    except WebSocketDisconnect:
        pass
    
    finally:
        manager.disconnect(websocket, job_id)
        await pubsub.unsubscribe(f"generation:{job_id}")


# Helper functions for publishing updates
async def publish_progress(job_id: str, step: str, percent: int, detail: str = None):
    """Publish progress update to WebSocket clients."""
    
    message = {
        "type": "progress",
        "step": step,
        "percent": percent,
        "detail": detail,
        "timestamp": datetime.utcnow().isoformat()
    }
    
    await redis_client.publish(f"generation:{job_id}", json.dumps(message))


async def publish_completion(document_id: str, pdf_url: str):
    """Publish completion to WebSocket clients."""
    
    # Get job_id from document
    job = get_job_by_document(document_id)
    
    message = {
        "type": "complete",
        "document_id": str(document_id),
        "pdf_url": pdf_url,
        "timestamp": datetime.utcnow().isoformat()
    }
    
    await redis_client.publish(f"generation:{job.id}", json.dumps(message))


async def publish_failure(document_id: str, error: str):
    """Publish failure to WebSocket clients."""
    
    job = get_job_by_document(document_id)
    
    message = {
        "type": "error",
        "document_id": str(document_id),
        "error": error,
        "timestamp": datetime.utcnow().isoformat()
    }
    
    await redis_client.publish(f"generation:{job.id}", json.dumps(message))
```

***

# 15. Error Handling

## 15.1 Error Code Registry

```python theme={null}
# app/utils/exceptions.py

from enum import Enum
from typing import Optional, Any
from fastapi import HTTPException


class ErrorCode(str, Enum):
    """Standard error codes for the API."""
    
    # Authentication (AUTH_xxx)
    AUTH_INVALID_CREDENTIALS = "AUTH_001"
    AUTH_TOKEN_EXPIRED = "AUTH_002"
    AUTH_TOKEN_INVALID = "AUTH_003"
    AUTH_INSUFFICIENT_PERMISSIONS = "AUTH_004"
    AUTH_ACCOUNT_LOCKED = "AUTH_005"
    AUTH_ACCOUNT_INACTIVE = "AUTH_006"
    AUTH_EMAIL_NOT_VERIFIED = "AUTH_007"
    
    # Validation (VAL_xxx)
    VAL_INVALID_INPUT = "VAL_001"
    VAL_MISSING_FIELD = "VAL_002"
    VAL_INVALID_FORMAT = "VAL_003"
    VAL_DUPLICATE_VALUE = "VAL_004"
    
    # Resource (RES_xxx)
    RES_NOT_FOUND = "RES_001"
    RES_ALREADY_EXISTS = "RES_002"
    RES_CONFLICT = "RES_003"
    RES_DELETED = "RES_004"
    
    # Business Logic (BIZ_xxx)
    BIZ_SEAT_LIMIT_EXCEEDED = "BIZ_001"
    BIZ_TEMPLATE_NOT_AVAILABLE = "BIZ_002"
    BIZ_SUBSCRIPTION_INVALID = "BIZ_003"
    BIZ_API_CREDITS_EXCEEDED = "BIZ_004"
    BIZ_FEATURE_NOT_AVAILABLE = "BIZ_005"
    BIZ_OAUTH_NOT_CONFIGURED = "BIZ_006"
    BIZ_OAUTH_EXPIRED = "BIZ_007"
    
    # Generation (GEN_xxx)
    GEN_FAILED = "GEN_001"
    GEN_RESEARCH_TIMEOUT = "GEN_002"
    GEN_TEMPLATE_ERROR = "GEN_003"
    GEN_PDF_RENDER_ERROR = "GEN_004"
    GEN_API_ERROR = "GEN_005"
    GEN_CONTENT_ERROR = "GEN_006"
    GEN_IMAGE_ERROR = "GEN_007"
    
    # Distribution (DIST_xxx)
    DIST_OAUTH_INVALID = "DIST_001"
    DIST_PLATFORM_ERROR = "DIST_002"
    DIST_RATE_LIMITED = "DIST_003"
    DIST_CONTENT_REJECTED = "DIST_004"
    
    # File/Storage (FILE_xxx)
    FILE_TOO_LARGE = "FILE_001"
    FILE_INVALID_TYPE = "FILE_002"
    FILE_UPLOAD_FAILED = "FILE_003"
    FILE_NOT_FOUND = "FILE_004"
    
    # Rate Limiting (RATE_xxx)
    RATE_LIMIT_EXCEEDED = "RATE_001"
    
    # System (SYS_xxx)
    SYS_INTERNAL_ERROR = "SYS_001"
    SYS_SERVICE_UNAVAILABLE = "SYS_002"
    SYS_MAINTENANCE = "SYS_003"


ERROR_MESSAGES = {
    ErrorCode.AUTH_INVALID_CREDENTIALS: "Invalid email or password",
    ErrorCode.AUTH_TOKEN_EXPIRED: "Authentication token has expired",
    ErrorCode.AUTH_TOKEN_INVALID: "Invalid authentication token",
    ErrorCode.AUTH_INSUFFICIENT_PERMISSIONS: "You don't have permission to perform this action",
    ErrorCode.AUTH_ACCOUNT_LOCKED: "Account is temporarily locked due to too many failed attempts",
    ErrorCode.AUTH_ACCOUNT_INACTIVE: "Account is inactive",
    ErrorCode.AUTH_EMAIL_NOT_VERIFIED: "Please verify your email address",
    
    ErrorCode.BIZ_SEAT_LIMIT_EXCEEDED: "Client limit exceeded for your plan",
    ErrorCode.BIZ_TEMPLATE_NOT_AVAILABLE: "This template is not available on your plan",
    ErrorCode.BIZ_SUBSCRIPTION_INVALID: "Your subscription is not active",
    ErrorCode.BIZ_API_CREDITS_EXCEEDED: "API credits for this billing period have been exceeded",
    ErrorCode.BIZ_FEATURE_NOT_AVAILABLE: "This feature is not available on your plan",
    ErrorCode.BIZ_OAUTH_NOT_CONFIGURED: "Social media connection not configured",
    ErrorCode.BIZ_OAUTH_EXPIRED: "Social media connection has expired, please reconnect",
    
    ErrorCode.GEN_FAILED: "Content generation failed",
    ErrorCode.GEN_RESEARCH_TIMEOUT: "Research took too long, please try a more specific topic",
    ErrorCode.GEN_TEMPLATE_ERROR: "Error applying document template",
    ErrorCode.GEN_PDF_RENDER_ERROR: "Error generating PDF",
    ErrorCode.GEN_API_ERROR: "AI service error",
    
    ErrorCode.RATE_LIMIT_EXCEEDED: "Too many requests, please try again later",
    
    ErrorCode.SYS_INTERNAL_ERROR: "An internal error occurred",
    ErrorCode.SYS_SERVICE_UNAVAILABLE: "Service temporarily unavailable",
}


class APIError(HTTPException):
    """Custom API exception with error code."""
    
    def __init__(
        self,
        code: ErrorCode,
        message: Optional[str] = None,
        details: Optional[Any] = None,
        status_code: int = 400
    ):
        self.code = code
        self.error_message = message or ERROR_MESSAGES.get(code, "An error occurred")
        self.details = details
        
        super().__init__(
            status_code=status_code,
            detail={
                "error": {
                    "code": code.value,
                    "message": self.error_message,
                    "details": details
                }
            }
        )


class GenerationError(Exception):
    """Exception during content generation."""
    
    def __init__(self, code: str, message: str, step: Optional[str] = None):
        self.code = code
        self.message = message
        self.step = step
        super().__init__(message)
```

## 15.2 Global Exception Handler

```python theme={null}
# app/middleware/error_handler.py

from fastapi import Request
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
import traceback


class ErrorHandlerMiddleware(BaseHTTPMiddleware):
    """Global error handling middleware."""
    
    async def dispatch(self, request: Request, call_next):
        try:
            return await call_next(request)
            
        except APIError as e:
            return JSONResponse(
                status_code=e.status_code,
                content={
                    "success": False,
                    "error": e.detail["error"],
                    "meta": {
                        "request_id": getattr(request.state, 'request_id', None),
                        "timestamp": datetime.utcnow().isoformat()
                    }
                }
            )
        
        except HTTPException as e:
            return JSONResponse(
                status_code=e.status_code,
                content={
                    "success": False,
                    "error": {
                        "code": "HTTP_ERROR",
                        "message": e.detail if isinstance(e.detail, str) else str(e.detail)
                    },
                    "meta": {
                        "request_id": getattr(request.state, 'request_id', None)
                    }
                }
            )
        
        except Exception as e:
            # Log full traceback
            logger.exception(f"Unhandled exception: {e}")
            
            # Don't expose internal errors in production
            if settings.DEBUG:
                error_detail = {
                    "exception": str(e),
                    "traceback": traceback.format_exc()
                }
            else:
                error_detail = None
            
            return JSONResponse(
                status_code=500,
                content={
                    "success": False,
                    "error": {
                        "code": ErrorCode.SYS_INTERNAL_ERROR.value,
                        "message": ERROR_MESSAGES[ErrorCode.SYS_INTERNAL_ERROR],
                        "details": error_detail
                    },
                    "meta": {
                        "request_id": getattr(request.state, 'request_id', None)
                    }
                }
            )
```

***

# 16. Rate Limiting

## 16.1 Rate Limiter Implementation

```python theme={null}
# app/middleware/rate_limiter.py

from fastapi import Request, HTTPException
from starlette.middleware.base import BaseHTTPMiddleware
import time


class RateLimiterMiddleware(BaseHTTPMiddleware):
    """
    Token bucket rate limiting using Redis.
    """
    
    def __init__(self, app, redis_client):
        super().__init__(app)
        self.redis = redis_client
    
    async def dispatch(self, request: Request, call_next):
        # Skip rate limiting for certain paths
        if self._should_skip(request.url.path):
            return await call_next(request)
        
        # Get rate limit key
        key = self._get_rate_limit_key(request)
        
        # Check rate limit
        allowed, remaining, reset_at = await self._check_rate_limit(key, request)
        
        if not allowed:
            raise HTTPException(
                status_code=429,
                detail={
                    "error": {
                        "code": "RATE_001",
                        "message": "Rate limit exceeded",
                        "retry_after": reset_at - int(time.time())
                    }
                },
                headers={
                    "X-RateLimit-Remaining": "0",
                    "X-RateLimit-Reset": str(reset_at),
                    "Retry-After": str(reset_at - int(time.time()))
                }
            )
        
        # Process request
        response = await call_next(request)
        
        # Add rate limit headers
        response.headers["X-RateLimit-Remaining"] = str(remaining)
        response.headers["X-RateLimit-Reset"] = str(reset_at)
        
        return response
    
    def _should_skip(self, path: str) -> bool:
        """Skip rate limiting for certain paths."""
        skip_paths = ['/health', '/docs', '/openapi.json']
        return any(path.startswith(p) for p in skip_paths)
    
    def _get_rate_limit_key(self, request: Request) -> str:
        """Generate rate limit key based on request."""
        
        # If API key, use that
        api_key = request.headers.get('X-API-Key')
        if api_key:
            return f"ratelimit:apikey:{api_key[:12]}"
        
        # If authenticated, use user ID
        if hasattr(request.state, 'user') and request.state.user:
            return f"ratelimit:user:{request.state.user.id}"
        
        # If agency context, use agency + IP
        if hasattr(request.state, 'agency') and request.state.agency:
            ip = request.client.host
            return f"ratelimit:agency:{request.state.agency.id}:{ip}"
        
        # Fallback to IP
        return f"ratelimit:ip:{request.client.host}"
    
    async def _check_rate_limit(
        self,
        key: str,
        request: Request
    ) -> tuple[bool, int, int]:
        """
        Check rate limit using sliding window.
        Returns (allowed, remaining, reset_timestamp).
        """
        
        # Get limits based on request type
        limits = self._get_limits(request)
        
        now = int(time.time())
        window_start = now - limits['window_seconds']
        
        # Use Redis sorted set for sliding window
        pipe = self.redis.pipeline()
        
        # Remove old entries
        pipe.zremrangebyscore(key, 0, window_start)
        
        # Count current requests
        pipe.zcard(key)
        
        # Add current request
        pipe.zadd(key, {str(now): now})
        
        # Set expiry
        pipe.expire(key, limits['window_seconds'])
        
        results = await pipe.execute()
        current_count = results[1]
        
        allowed = current_count < limits['max_requests']
        remaining = max(0, limits['max_requests'] - current_count - 1)
        reset_at = now + limits['window_seconds']
        
        return allowed, remaining, reset_at
    
    def _get_limits(self, request: Request) -> dict:
        """Get rate limits based on request context."""
        
        # API key requests (configurable per key)
        if request.headers.get('X-API-Key'):
            return {'max_requests': 60, 'window_seconds': 60}
        
        # Generation endpoints (stricter)
        if '/generate' in request.url.path:
            return {'max_requests': 10, 'window_seconds': 60}
        
        # Default limits
        return {'max_requests': 100, 'window_seconds': 60}
```

***

# 17. Logging & Monitoring

## 17.1 Structured Logging Configuration

```python theme={null}
# app/utils/logging.py

import structlog
from structlog.stdlib import filter_by_level
import logging


def configure_logging(log_level: str = "INFO"):
    """Configure structured logging."""
    
    # Configure structlog
    structlog.configure(
        processors=[
            structlog.contextvars.merge_contextvars,
            structlog.stdlib.filter_by_level,
            structlog.stdlib.add_logger_name,
            structlog.stdlib.add_log_level,
            structlog.stdlib.PositionalArgumentsFormatter(),
            structlog.processors.TimeStamper(fmt="iso"),
            structlog.processors.StackInfoRenderer(),
            structlog.processors.format_exc_info,
            structlog.processors.UnicodeDecoder(),
            structlog.processors.JSONRenderer()
        ],
        context_class=dict,
        logger_factory=structlog.stdlib.LoggerFactory(),
        wrapper_class=structlog.stdlib.BoundLogger,
        cache_logger_on_first_use=True,
    )
    
    # Configure standard logging
    logging.basicConfig(
        format="%(message)s",
        level=getattr(logging, log_level.upper()),
    )


def get_logger(name: str):
    """Get a structured logger."""
    return structlog.get_logger(name)


# Usage example:
# logger = get_logger(__name__)
# logger.info("document_generated", document_id=doc_id, duration=elapsed)
```

## 17.2 Request Logging Middleware

```python theme={null}
# app/middleware/request_logging.py

from fastapi import Request
from starlette.middleware.base import BaseHTTPMiddleware
import time
import uuid


class RequestLoggingMiddleware(BaseHTTPMiddleware):
    """Log all requests with timing and context."""
    
    async def dispatch(self, request: Request, call_next):
        # Generate request ID
        request_id = str(uuid.uuid4())
        request.state.request_id = request_id
        
        # Start timing
        start_time = time.time()
        
        # Log request start
        logger.info(
            "request_started",
            request_id=request_id,
            method=request.method,
            path=request.url.path,
            query=str(request.query_params),
            client_ip=request.client.host,
            user_agent=request.headers.get('user-agent'),
            agency_id=getattr(request.state, 'agency', {}).get('id') if hasattr(request.state, 'agency') else None
        )
        
        # Process request
        try:
            response = await call_next(request)
            
            # Calculate duration
            duration = time.time() - start_time
            
            # Log request completion
            logger.info(
                "request_completed",
                request_id=request_id,
                method=request.method,
                path=request.url.path,
                status_code=response.status_code,
                duration_ms=round(duration * 1000, 2)
            )
            
            # Add request ID to response headers
            response.headers['X-Request-ID'] = request_id
            
            return response
            
        except Exception as e:
            duration = time.time() - start_time
            
            logger.error(
                "request_failed",
                request_id=request_id,
                method=request.method,
                path=request.url.path,
                error=str(e),
                duration_ms=round(duration * 1000, 2)
            )
            
            raise
```

***

# 18. Security Considerations

## 18.1 Security Checklist

| Area             | Implementation                                                      |
| ---------------- | ------------------------------------------------------------------- |
| Authentication   | JWT with short expiry (15 min), refresh tokens in HTTP-only cookies |
| Password Storage | bcrypt with cost factor 12                                          |
| API Keys         | SHA-256 hashed, prefixed for identification                         |
| Sensitive Data   | Encrypted at rest (Fernet symmetric encryption)                     |
| SQL Injection    | SQLAlchemy ORM with parameterized queries                           |
| XSS              | Pydantic validation, proper escaping in templates                   |
| CSRF             | SameSite cookies, custom headers for API                            |
| Rate Limiting    | Redis-based sliding window                                          |
| Input Validation | Pydantic schemas for all inputs                                     |
| File Uploads     | Type validation, size limits, isolated storage                      |

## 18.2 Encryption Service

```python theme={null}
# app/services/encryption_service.py

from cryptography.fernet import Fernet
from base64 import urlsafe_b64encode
import hashlib


class EncryptionService:
    """Handle encryption of sensitive data."""
    
    def __init__(self, encryption_key: str):
        # Derive Fernet key from provided key
        key = hashlib.sha256(encryption_key.encode()).digest()
        self.fernet = Fernet(urlsafe_b64encode(key))
    
    def encrypt(self, data: str) -> str:
        """Encrypt string data."""
        return self.fernet.encrypt(data.encode()).decode()
    
    def decrypt(self, encrypted_data: str) -> str:
        """Decrypt string data."""
        return self.fernet.decrypt(encrypted_data.encode()).decode()
    
    def encrypt_json(self, data: dict) -> str:
        """Encrypt JSON data."""
        import json
        return self.encrypt(json.dumps(data))
    
    def decrypt_json(self, encrypted_data: str) -> dict:
        """Decrypt JSON data."""
        import json
        return json.loads(self.decrypt(encrypted_data))


# Hash API keys for storage
def hash_api_key(key: str) -> str:
    """Hash API key for storage."""
    return hashlib.sha256(key.encode()).hexdigest()


def generate_api_key() -> tuple[str, str]:
    """Generate new API key. Returns (full_key, key_hash)."""
    import secrets
    
    # Format: csa_live_<32 random chars>
    random_part = secrets.token_urlsafe(24)  # 32 chars
    full_key = f"csa_live_{random_part}"
    
    return full_key, hash_api_key(full_key)
```

***

# 19. Environment Configuration

## 19.1 Environment Variables

```bash theme={null}
# .env.example

# ═══════════════════════════════════════════════════════════════
# APPLICATION
# ═══════════════════════════════════════════════════════════════
APP_ENV=production                    # development, staging, production
APP_DEBUG=false
APP_SECRET_KEY=your-256-bit-secret-key-here
APP_URL=https://api.contentstrategist.com

# ═══════════════════════════════════════════════════════════════
# DATABASE
# ═══════════════════════════════════════════════════════════════
DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/content_strategist
DATABASE_POOL_SIZE=20
DATABASE_MAX_OVERFLOW=10

# ═══════════════════════════════════════════════════════════════
# REDIS
# ═══════════════════════════════════════════════════════════════
REDIS_URL=redis://localhost:6379/0

# ═══════════════════════════════════════════════════════════════
# STORAGE
# ═══════════════════════════════════════════════════════════════
STORAGE_PROVIDER=local                # local, s3
STORAGE_LOCAL_PATH=/var/storage/content-strategist
STORAGE_PUBLIC_URL=https://authapi.net/files

# S3 Configuration (if STORAGE_PROVIDER=s3)
STORAGE_S3_BUCKET=content-strategist-files
STORAGE_S3_REGION=us-east-1
STORAGE_S3_ACCESS_KEY=
STORAGE_S3_SECRET_KEY=
STORAGE_S3_ENDPOINT=                  # For S3-compatible services

# ═══════════════════════════════════════════════════════════════
# EXTERNAL APIS (Default keys, agencies can override)
# ═══════════════════════════════════════════════════════════════
ANTHROPIC_API_KEY=sk-ant-...
FREEPIK_API_KEY=fpk-...

# ═══════════════════════════════════════════════════════════════
# OAUTH APP CREDENTIALS
# ═══════════════════════════════════════════════════════════════
LINKEDIN_CLIENT_ID=
LINKEDIN_CLIENT_SECRET=
FACEBOOK_APP_ID=
FACEBOOK_APP_SECRET=
TWITTER_CLIENT_ID=
TWITTER_CLIENT_SECRET=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=

# ═══════════════════════════════════════════════════════════════
# SECURITY
# ═══════════════════════════════════════════════════════════════
ENCRYPTION_KEY=your-32-byte-encryption-key
JWT_SECRET_KEY=your-jwt-secret-key
JWT_ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=15
REFRESH_TOKEN_EXPIRE_DAYS=7

# ═══════════════════════════════════════════════════════════════
# CORS
# ═══════════════════════════════════════════════════════════════
CORS_ORIGINS=["https://contentstrategist.com","https://*.contentstrategist.com"]

# ═══════════════════════════════════════════════════════════════
# LOGGING
# ═══════════════════════════════════════════════════════════════
LOG_LEVEL=INFO
LOG_FORMAT=json

# ═══════════════════════════════════════════════════════════════
# CELERY
# ═══════════════════════════════════════════════════════════════
CELERY_BROKER_URL=redis://localhost:6379/0
CELERY_RESULT_BACKEND=redis://localhost:6379/0
```

***

# 20. Deployment

## 20.1 Docker Configuration

```dockerfile theme={null}
# docker/Dockerfile

FROM python:3.11-slim

# Install system dependencies for WeasyPrint
RUN apt-get update && apt-get install -y \
    build-essential \
    libpango-1.0-0 \
    libpangocairo-1.0-0 \
    libcairo2 \
    libffi-dev \
    libgdk-pixbuf2.0-0 \
    shared-mime-info \
    && rm -rf /var/lib/apt/lists/*

# Set working directory
WORKDIR /app

# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy application code
COPY . .

# Create non-root user
RUN useradd -m appuser && chown -R appuser:appuser /app
USER appuser

# Expose port
EXPOSE 8000

# Run application
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
```

## 20.2 Docker Compose

```yaml theme={null}
# docker/docker-compose.yml

version: '3.8'

services:
  api:
    build:
      context: ..
      dockerfile: docker/Dockerfile
    ports:
      - "8000:8000"
    environment:
      - DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/content_strategist
      - REDIS_URL=redis://redis:6379/0
    env_file:
      - ../.env
    depends_on:
      - db
      - redis
    volumes:
      - ../storage:/var/storage/content-strategist
    restart: unless-stopped

  worker:
    build:
      context: ..
      dockerfile: docker/Dockerfile
    command: celery -A app.workers.celery_app worker --loglevel=info -Q generation,distribution
    environment:
      - DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/content_strategist
      - REDIS_URL=redis://redis:6379/0
    env_file:
      - ../.env
    depends_on:
      - db
      - redis
    volumes:
      - ../storage:/var/storage/content-strategist
    restart: unless-stopped

  scheduler:
    build:
      context: ..
      dockerfile: docker/Dockerfile
    command: celery -A app.workers.celery_app beat --loglevel=info
    environment:
      - DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/content_strategist
      - REDIS_URL=redis://redis:6379/0
    env_file:
      - ../.env
    depends_on:
      - db
      - redis
    restart: unless-stopped

  db:
    image: postgres:15-alpine
    environment:
      - POSTGRES_USER=postgres
      - POSTGRES_PASSWORD=postgres
      - POSTGRES_DB=content_strategist
    volumes:
      - postgres_data:/var/lib/postgresql/data
    restart: unless-stopped

  redis:
    image: redis:7-alpine
    volumes:
      - redis_data:/data
    restart: unless-stopped

volumes:
  postgres_data:
  redis_data:
```

## 20.3 Dokploy Configuration

```yaml theme={null}
# dokploy.yaml

name: content-strategist
version: "1.0"

services:
  api:
    type: docker
    dockerfile: docker/Dockerfile
    port: 8000
    replicas: 2
    health_check:
      path: /health
      interval: 30s
    resources:
      cpu: 1
      memory: 2Gi
    env_file: .env.production

  worker:
    type: docker
    dockerfile: docker/Dockerfile
    command: celery -A app.workers.celery_app worker --loglevel=info
    replicas: 2
    resources:
      cpu: 2
      memory: 4Gi
    env_file: .env.production

  scheduler:
    type: docker
    dockerfile: docker/Dockerfile
    command: celery -A app.workers.celery_app beat --loglevel=info
    replicas: 1
    resources:
      cpu: 0.5
      memory: 512Mi
    env_file: .env.production

databases:
  postgresql:
    version: "15"
    storage: 50Gi
    
  redis:
    version: "7"
    storage: 5Gi

domains:
  - api.contentstrategist.com
  - "*.contentstrategist.com"

ssl:
  provider: letsencrypt
  auto_renew: true
```

***

# 21. Testing Requirements

## 21.1 Test Categories

| Category          | Description                  | Target Coverage |
| ----------------- | ---------------------------- | --------------- |
| Unit Tests        | Individual functions/methods | 80%             |
| Integration Tests | API endpoints, database      | 70%             |
| E2E Tests         | Full generation flow         | Critical paths  |

## 21.2 Test Configuration

```python theme={null}
# tests/conftest.py

import pytest
import asyncio
from httpx import AsyncClient
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from app.main import app
from app.database import get_db
from app.models import Base


# Test database URL
TEST_DATABASE_URL = "postgresql+asyncpg://postgres:postgres@localhost:5432/content_strategist_test"


@pytest.fixture(scope="session")
def event_loop():
    """Create event loop for async tests."""
    loop = asyncio.get_event_loop_policy().new_event_loop()
    yield loop
    loop.close()


@pytest.fixture(scope="session")
async def test_engine():
    """Create test database engine."""
    engine = create_async_engine(TEST_DATABASE_URL, echo=False)
    
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    
    yield engine
    
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.drop_all)
    
    await engine.dispose()


@pytest.fixture
async def db_session(test_engine):
    """Create test database session."""
    async with AsyncSession(test_engine) as session:
        yield session
        await session.rollback()


@pytest.fixture
async def client(db_session):
    """Create test HTTP client."""
    
    async def override_get_db():
        yield db_session
    
    app.dependency_overrides[get_db] = override_get_db
    
    async with AsyncClient(app=app, base_url="http://test") as client:
        yield client
    
    app.dependency_overrides.clear()


@pytest.fixture
async def authenticated_client(client, test_user):
    """Create authenticated test client."""
    # Login and get token
    response = await client.post("/api/v1/auth/login", json={
        "email": test_user.email,
        "password": "testpassword123"
    })
    token = response.json()["data"]["access_token"]
    
    client.headers["Authorization"] = f"Bearer {token}"
    return client
```

## 21.3 Example Tests

```python theme={null}
# tests/integration/test_document_generation.py

import pytest
from uuid import uuid4


class TestDocumentGeneration:
    """Integration tests for document generation."""
    
    @pytest.mark.asyncio
    async def test_start_generation_success(self, authenticated_client, test_client):
        """Test successful generation start."""
        
        response = await authenticated_client.post(
            f"/api/v1/clients/{test_client.id}/documents/generate",
            json={
                "topic": "AI Implementation Strategies",
                "tone": "professional",
                "keywords": ["AI", "strategy"]
            }
        )
        
        assert response.status_code == 202
        data = response.json()["data"]
        assert "document_id" in data
        assert "job_id" in data
        assert data["status"] == "pending"
    
    @pytest.mark.asyncio
    async def test_start_generation_missing_topic(self, authenticated_client, test_client):
        """Test generation fails without topic."""
        
        response = await authenticated_client.post(
            f"/api/v1/clients/{test_client.id}/documents/generate",
            json={
                "tone": "professional"
            }
        )
        
        assert response.status_code == 422
    
    @pytest.mark.asyncio
    async def test_start_generation_invalid_client(self, authenticated_client):
        """Test generation fails for non-existent client."""
        
        fake_id = str(uuid4())
        response = await authenticated_client.post(
            f"/api/v1/clients/{fake_id}/documents/generate",
            json={
                "topic": "Test Topic"
            }
        )
        
        assert response.status_code == 404
```

***

# Appendix A: API Quick Reference

## Endpoints Summary

| Method | Endpoint                                  | Description            |
| ------ | ----------------------------------------- | ---------------------- |
| POST   | `/api/v1/auth/login`                      | Login                  |
| POST   | `/api/v1/auth/refresh`                    | Refresh token          |
| GET    | `/api/v1/auth/me`                         | Current user           |
| GET    | `/api/v1/clients`                         | List clients           |
| POST   | `/api/v1/clients`                         | Create client          |
| GET    | `/api/v1/clients/{id}`                    | Get client             |
| PUT    | `/api/v1/clients/{id}`                    | Update client          |
| POST   | `/api/v1/clients/{id}/documents/generate` | Start generation       |
| GET    | `/api/v1/documents/{id}`                  | Get document           |
| GET    | `/api/v1/documents/{id}/status`           | Get generation status  |
| POST   | `/api/v1/documents/{id}/distribute`       | Distribute document    |
| GET    | `/api/v1/clients/{id}/schedule`           | List scheduled content |
| POST   | `/api/v1/clients/{id}/schedule/import`    | Import CSV             |
| GET    | `/api/v1/templates`                       | List templates         |
| GET    | `/api/v1/agency`                          | Get agency settings    |
| PUT    | `/api/v1/agency`                          | Update agency          |

***

# Document Version History

| Version | Date        | Author | Changes                        |
| ------- | ----------- | ------ | ------------------------------ |
| 2.0     | Jan 2, 2026 | Claude | Complete comprehensive rewrite |

***

**END OF DEVELOPER PRD**

***

# 22. Frontend Architecture

## 22.1 Frontend Technology Stack

| Component     | Technology       | Version | Purpose                         |
| ------------- | ---------------- | ------- | ------------------------------- |
| Framework     | Next.js          | 14+     | React framework with App Router |
| Language      | TypeScript       | 5.3+    | Type-safe JavaScript            |
| Styling       | Tailwind CSS     | 3.4+    | Utility-first CSS               |
| Components    | shadcn/ui        | latest  | Accessible component primitives |
| State         | Zustand          | 4.5+    | Lightweight state management    |
| Data Fetching | TanStack Query   | 5+      | Server state management         |
| Forms         | React Hook Form  | 7.49+   | Performant form handling        |
| Validation    | Zod              | 3.22+   | Schema validation               |
| HTTP Client   | Axios            | 1.6+    | API requests                    |
| WebSocket     | socket.io-client | 4.7+    | Real-time updates               |
| Charts        | Recharts         | 2.10+   | Data visualization              |
| Tables        | TanStack Table   | 8.11+   | Headless table logic            |
| Date Handling | date-fns         | 3.0+    | Date utilities                  |
| Icons         | Lucide React     | 0.300+  | Icon library                    |
| PDF Preview   | react-pdf        | 7.7+    | PDF rendering                   |
| File Upload   | react-dropzone   | 14.2+   | Drag-and-drop uploads           |
| Toast         | Sonner           | 1.3+    | Toast notifications             |
| Animations    | Framer Motion    | 10.18+  | Animations                      |

## 22.2 Frontend Project Structure

```
frontend/
│
├── public/
│   ├── favicon.ico
│   ├── logo.svg
│   └── images/
│       └── placeholder-cover.jpg
│
├── src/
│   ├── app/                              # Next.js App Router
│   │   ├── (auth)/                       # Auth route group (no layout)
│   │   │   ├── login/
│   │   │   │   └── page.tsx
│   │   │   ├── register/
│   │   │   │   └── page.tsx
│   │   │   ├── forgot-password/
│   │   │   │   └── page.tsx
│   │   │   └── reset-password/
│   │   │       └── page.tsx
│   │   │
│   │   ├── (dashboard)/                  # Dashboard route group (shared layout)
│   │   │   ├── layout.tsx                # Dashboard layout with sidebar
│   │   │   ├── page.tsx                  # Dashboard home (redirect or overview)
│   │   │   │
│   │   │   ├── dashboard/
│   │   │   │   └── page.tsx              # Main dashboard view
│   │   │   │
│   │   │   ├── clients/
│   │   │   │   ├── page.tsx              # Client list
│   │   │   │   ├── new/
│   │   │   │   │   └── page.tsx          # Create client
│   │   │   │   └── [clientId]/
│   │   │   │       ├── page.tsx          # Client overview (default tab)
│   │   │   │       ├── documents/
│   │   │   │       │   └── page.tsx      # Client documents tab
│   │   │   │       ├── schedule/
│   │   │   │       │   └── page.tsx      # Client schedule tab
│   │   │   │       ├── settings/
│   │   │   │       │   └── page.tsx      # Client settings tab
│   │   │   │       └── edit/
│   │   │   │           └── page.tsx      # Edit client
│   │   │   │
│   │   │   ├── documents/
│   │   │   │   ├── page.tsx              # All documents list
│   │   │   │   └── [documentId]/
│   │   │   │       └── page.tsx          # Document detail view
│   │   │   │
│   │   │   ├── generate/
│   │   │   │   ├── page.tsx              # Generation form (select client first)
│   │   │   │   ├── [clientId]/
│   │   │   │   │   └── page.tsx          # Generation form for specific client
│   │   │   │   └── progress/
│   │   │   │       └── [jobId]/
│   │   │   │           └── page.tsx      # Generation progress view
│   │   │   │
│   │   │   ├── schedule/
│   │   │   │   ├── page.tsx              # Schedule calendar/list view
│   │   │   │   ├── new/
│   │   │   │   │   └── page.tsx          # Create scheduled item
│   │   │   │   ├── import/
│   │   │   │   │   └── page.tsx          # CSV import page
│   │   │   │   └── [scheduleId]/
│   │   │   │       └── edit/
│   │   │   │           └── page.tsx      # Edit scheduled item
│   │   │   │
│   │   │   ├── templates/
│   │   │   │   └── page.tsx              # Browse available templates
│   │   │   │
│   │   │   ├── team/
│   │   │   │   ├── page.tsx              # Team members list
│   │   │   │   ├── invite/
│   │   │   │   │   └── page.tsx          # Invite team member
│   │   │   │   └── [userId]/
│   │   │   │       └── edit/
│   │   │   │           └── page.tsx      # Edit team member
│   │   │   │
│   │   │   └── settings/
│   │   │       ├── page.tsx              # Settings overview/general
│   │   │       ├── general/
│   │   │       │   └── page.tsx          # General settings
│   │   │       ├── branding/
│   │   │       │   └── page.tsx          # Branding settings
│   │   │       ├── integrations/
│   │   │       │   └── page.tsx          # Social integrations
│   │   │       ├── api-keys/
│   │   │       │   └── page.tsx          # API key management
│   │   │       └── billing/
│   │   │           └── page.tsx          # Billing/plan info
│   │   │
│   │   ├── (admin)/                      # Super Admin route group
│   │   │   ├── layout.tsx                # Admin layout
│   │   │   └── admin/
│   │   │       ├── page.tsx              # Admin dashboard
│   │   │       ├── agencies/
│   │   │       │   ├── page.tsx          # Agency list
│   │   │       │   ├── new/
│   │   │       │   │   └── page.tsx      # Create agency
│   │   │       │   └── [agencyId]/
│   │   │       │       └── page.tsx      # Agency detail/edit
│   │   │       ├── templates/
│   │   │       │   ├── page.tsx          # Template management
│   │   │       │   ├── new/
│   │   │       │   │   └── page.tsx      # Create template
│   │   │       │   └── [templateId]/
│   │   │       │       └── edit/
│   │   │       │           └── page.tsx  # Edit template
│   │   │       ├── plans/
│   │   │       │   └── page.tsx          # Plan management
│   │   │       └── system/
│   │   │           └── page.tsx          # System health/stats
│   │   │
│   │   ├── api/                          # API routes (if needed for BFF)
│   │   │   └── auth/
│   │   │       └── [...nextauth]/
│   │   │           └── route.ts
│   │   │
│   │   ├── layout.tsx                    # Root layout
│   │   ├── loading.tsx                   # Global loading state
│   │   ├── error.tsx                     # Global error boundary
│   │   ├── not-found.tsx                 # 404 page
│   │   └── globals.css                   # Global styles
│   │
│   ├── components/
│   │   ├── ui/                           # shadcn/ui components
│   │   │   ├── button.tsx
│   │   │   ├── input.tsx
│   │   │   ├── select.tsx
│   │   │   ├── checkbox.tsx
│   │   │   ├── radio-group.tsx
│   │   │   ├── switch.tsx
│   │   │   ├── textarea.tsx
│   │   │   ├── label.tsx
│   │   │   ├── card.tsx
│   │   │   ├── dialog.tsx
│   │   │   ├── dropdown-menu.tsx
│   │   │   ├── popover.tsx
│   │   │   ├── tooltip.tsx
│   │   │   ├── tabs.tsx
│   │   │   ├── table.tsx
│   │   │   ├── badge.tsx
│   │   │   ├── avatar.tsx
│   │   │   ├── progress.tsx
│   │   │   ├── skeleton.tsx
│   │   │   ├── separator.tsx
│   │   │   ├── sheet.tsx
│   │   │   ├── alert.tsx
│   │   │   ├── alert-dialog.tsx
│   │   │   ├── calendar.tsx
│   │   │   ├── command.tsx
│   │   │   ├── form.tsx
│   │   │   └── scroll-area.tsx
│   │   │
│   │   ├── layout/                       # Layout components
│   │   │   ├── sidebar.tsx               # Main navigation sidebar
│   │   │   ├── sidebar-nav.tsx           # Sidebar navigation items
│   │   │   ├── header.tsx                # Top header bar
│   │   │   ├── breadcrumbs.tsx           # Breadcrumb navigation
│   │   │   ├── page-header.tsx           # Page title + actions
│   │   │   ├── mobile-nav.tsx            # Mobile bottom navigation
│   │   │   └── user-menu.tsx             # User dropdown menu
│   │   │
│   │   ├── dashboard/                    # Dashboard-specific components
│   │   │   ├── stat-card.tsx             # Statistics card
│   │   │   ├── recent-documents.tsx      # Recent docs list
│   │   │   ├── quick-actions.tsx         # Quick action buttons
│   │   │   ├── activity-chart.tsx        # Activity line chart
│   │   │   └── upcoming-schedule.tsx     # Upcoming items
│   │   │
│   │   ├── clients/                      # Client-related components
│   │   │   ├── client-card.tsx           # Client card for grid
│   │   │   ├── client-list.tsx           # Client list/grid container
│   │   │   ├── client-form.tsx           # Create/edit client form
│   │   │   ├── client-header.tsx         # Client detail header
│   │   │   ├── client-tabs.tsx           # Client detail tabs
│   │   │   ├── client-stats.tsx          # Client statistics row
│   │   │   ├── client-filters.tsx        # Search/filter controls
│   │   │   └── logo-upload.tsx           # Client logo uploader
│   │   │
│   │   ├── documents/                    # Document-related components
│   │   │   ├── document-card.tsx         # Document card for grid
│   │   │   ├── document-table.tsx        # Document data table
│   │   │   ├── document-preview.tsx      # PDF preview component
│   │   │   ├── document-header.tsx       # Document detail header
│   │   │   ├── document-details.tsx      # Generation details panel
│   │   │   ├── document-actions.tsx      # Action buttons
│   │   │   ├── document-filters.tsx      # Search/filter controls
│   │   │   └── distribution-status.tsx   # Distribution status panel
│   │   │
│   │   ├── generation/                   # Content generation components
│   │   │   ├── generation-form.tsx       # Main generation form
│   │   │   ├── topic-input.tsx           # Topic input with suggestions
│   │   │   ├── template-select.tsx       # Template selection dropdown
│   │   │   ├── tone-select.tsx           # Tone selection
│   │   │   ├── keyword-input.tsx         # Keyword tag input
│   │   │   ├── service-checkboxes.tsx    # Service selection
│   │   │   ├── distribution-settings.tsx # Distribution config
│   │   │   ├── advanced-options.tsx      # Collapsible advanced options
│   │   │   ├── generation-progress.tsx   # Progress display
│   │   │   ├── progress-steps.tsx        # Step indicator
│   │   │   ├── activity-log.tsx          # Real-time activity log
│   │   │   └── generation-complete.tsx   # Completion view
│   │   │
│   │   ├── schedule/                     # Schedule-related components
│   │   │   ├── schedule-calendar.tsx     # Calendar view
│   │   │   ├── schedule-list.tsx         # List view
│   │   │   ├── schedule-item.tsx         # Single scheduled item
│   │   │   ├── schedule-form.tsx         # Create/edit form
│   │   │   ├── csv-import.tsx            # CSV import wizard
│   │   │   ├── csv-preview.tsx           # CSV validation preview
│   │   │   ├── day-detail.tsx            # Selected day detail panel
│   │   │   └── recurrence-picker.tsx     # Recurrence options
│   │   │
│   │   ├── distribution/                 # Distribution components
│   │   │   ├── distribution-modal.tsx    # Distribution dialog
│   │   │   ├── platform-select.tsx       # Platform checkboxes
│   │   │   ├── post-preview.tsx          # Social post preview
│   │   │   ├── distribution-results.tsx  # Results display
│   │   │   └── connection-status.tsx     # Platform connection status
│   │   │
│   │   ├── templates/                    # Template-related components
│   │   │   ├── template-card.tsx         # Template preview card
│   │   │   ├── template-grid.tsx         # Template gallery
│   │   │   └── template-preview.tsx      # Full template preview
│   │   │
│   │   ├── team/                         # Team management components
│   │   │   ├── team-table.tsx            # Team members table
│   │   │   ├── invite-form.tsx           # Invite member form
│   │   │   ├── role-select.tsx           # Role selection
│   │   │   └── member-actions.tsx        # Member action buttons
│   │   │
│   │   ├── settings/                     # Settings components
│   │   │   ├── settings-nav.tsx          # Settings sidebar nav
│   │   │   ├── general-form.tsx          # General settings form
│   │   │   ├── branding-form.tsx         # Branding settings form
│   │   │   ├── color-picker.tsx          # Brand color picker
│   │   │   ├── logo-upload.tsx           # Logo upload component
│   │   │   ├── integration-card.tsx      # Social integration card
│   │   │   ├── api-key-table.tsx         # API keys table
│   │   │   ├── api-key-form.tsx          # Create API key form
│   │   │   └── plan-display.tsx          # Current plan display
│   │   │
│   │   ├── admin/                        # Admin-specific components
│   │   │   ├── agency-table.tsx          # Agency management table
│   │   │   ├── agency-form.tsx           # Agency create/edit form
│   │   │   ├── template-form.tsx         # Template create/edit form
│   │   │   ├── plan-form.tsx             # Plan configuration form
│   │   │   ├── system-stats.tsx          # System statistics
│   │   │   └── health-status.tsx         # Health check display
│   │   │
│   │   └── shared/                       # Shared/common components
│   │       ├── logo.tsx                  # App logo component
│   │       ├── loading-spinner.tsx       # Loading spinner
│   │       ├── loading-skeleton.tsx      # Skeleton loaders
│   │       ├── empty-state.tsx           # Empty state display
│   │       ├── error-state.tsx           # Error state display
│   │       ├── confirm-dialog.tsx        # Confirmation dialog
│   │       ├── data-table.tsx            # Generic data table
│   │       ├── pagination.tsx            # Pagination controls
│   │       ├── search-input.tsx          # Search input with debounce
│   │       ├── date-picker.tsx           # Date picker wrapper
│   │       ├── date-range-picker.tsx     # Date range picker
│   │       ├── file-upload.tsx           # Generic file upload
│   │       ├── image-upload.tsx          # Image upload with preview
│   │       ├── rich-text-editor.tsx      # Rich text input (if needed)
│   │       ├── copy-button.tsx           # Copy to clipboard
│   │       ├── status-badge.tsx          # Status indicator badge
│   │       └── time-ago.tsx              # Relative time display
│   │
│   ├── hooks/                            # Custom React hooks
│   │   ├── use-auth.ts                   # Authentication hook
│   │   ├── use-agency.ts                 # Current agency context
│   │   ├── use-user.ts                   # Current user data
│   │   ├── use-permissions.ts            # Permission checking
│   │   ├── use-websocket.ts              # WebSocket connection
│   │   ├── use-generation-progress.ts    # Generation progress subscription
│   │   ├── use-debounce.ts               # Debounce hook
│   │   ├── use-local-storage.ts          # Local storage hook
│   │   ├── use-media-query.ts            # Responsive breakpoints
│   │   ├── use-clipboard.ts              # Clipboard operations
│   │   └── use-toast.ts                  # Toast notifications
│   │
│   ├── lib/                              # Library/utility code
│   │   ├── api/                          # API client layer
│   │   │   ├── client.ts                 # Axios instance configuration
│   │   │   ├── auth.ts                   # Auth API calls
│   │   │   ├── agencies.ts               # Agency API calls
│   │   │   ├── clients.ts                # Client API calls
│   │   │   ├── documents.ts              # Document API calls
│   │   │   ├── generation.ts             # Generation API calls
│   │   │   ├── schedule.ts               # Schedule API calls
│   │   │   ├── templates.ts              # Template API calls
│   │   │   ├── team.ts                   # Team API calls
│   │   │   ├── distribution.ts           # Distribution API calls
│   │   │   ├── settings.ts               # Settings API calls
│   │   │   └── admin.ts                  # Admin API calls
│   │   │
│   │   ├── utils.ts                      # General utilities
│   │   ├── constants.ts                  # App constants
│   │   ├── validations.ts                # Zod schemas
│   │   ├── format.ts                     # Formatting utilities
│   │   ├── dates.ts                      # Date utilities
│   │   └── colors.ts                     # Color utilities
│   │
│   ├── stores/                           # Zustand stores
│   │   ├── auth-store.ts                 # Authentication state
│   │   ├── agency-store.ts               # Agency context state
│   │   ├── ui-store.ts                   # UI state (sidebar, modals)
│   │   ├── generation-store.ts           # Generation progress state
│   │   └── notification-store.ts         # Notification state
│   │
│   ├── types/                            # TypeScript type definitions
│   │   ├── api.ts                        # API response types
│   │   ├── auth.ts                       # Auth types
│   │   ├── agency.ts                     # Agency types
│   │   ├── client.ts                     # Client types
│   │   ├── document.ts                   # Document types
│   │   ├── template.ts                   # Template types
│   │   ├── schedule.ts                   # Schedule types
│   │   ├── user.ts                       # User types
│   │   ├── generation.ts                 # Generation types
│   │   └── index.ts                      # Export all types
│   │
│   ├── config/                           # Configuration
│   │   ├── site.ts                       # Site metadata
│   │   ├── navigation.ts                 # Navigation structure
│   │   └── api.ts                        # API configuration
│   │
│   └── styles/                           # Additional styles
│       └── pdf-preview.css               # PDF preview specific styles
│
├── .env.example                          # Environment template
├── .env.local                            # Local environment (git ignored)
├── .eslintrc.json                        # ESLint configuration
├── .prettierrc                           # Prettier configuration
├── next.config.js                        # Next.js configuration
├── tailwind.config.ts                    # Tailwind configuration
├── tsconfig.json                         # TypeScript configuration
├── postcss.config.js                     # PostCSS configuration
├── components.json                       # shadcn/ui configuration
└── package.json                          # Dependencies
```

## 22.3 State Management Architecture

### 22.3.1 State Categories

```
┌─────────────────────────────────────────────────────────────────────────────┐
│                         STATE MANAGEMENT ARCHITECTURE                        │
└─────────────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────────────┐
│                              SERVER STATE                                    │
│                         (TanStack Query / SWR)                              │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  • API Data (clients, documents, templates, etc.)                           │
│  • Cached and automatically refreshed                                       │
│  • Handles loading, error, and stale states                                 │
│  • Mutations with optimistic updates                                        │
│                                                                             │
│  Examples:                                                                  │
│  - useQuery(['clients']) → Client list                                      │
│  - useQuery(['documents', clientId]) → Client's documents                   │
│  - useMutation(createClient) → Create with cache invalidation               │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘
                                     │
                                     ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│                              CLIENT STATE                                    │
│                              (Zustand)                                       │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  ┌─────────────────┐  ┌─────────────────┐  ┌─────────────────┐             │
│  │   Auth Store    │  │  Agency Store   │  │    UI Store     │             │
│  ├─────────────────┤  ├─────────────────┤  ├─────────────────┤             │
│  │ • user          │  │ • currentAgency │  │ • sidebarOpen   │             │
│  │ • tokens        │  │ • branding      │  │ • activeModal   │             │
│  │ • isAuthenticated│ │ • permissions   │  │ • theme         │             │
│  │ • login()       │  │ • setAgency()   │  │ • toggleSidebar()│            │
│  │ • logout()      │  │                 │  │ • openModal()   │             │
│  └─────────────────┘  └─────────────────┘  └─────────────────┘             │
│                                                                             │
│  ┌─────────────────┐  ┌─────────────────┐                                  │
│  │ Generation Store│  │Notification Store│                                 │
│  ├─────────────────┤  ├─────────────────┤                                  │
│  │ • activeJobs    │  │ • notifications │                                  │
│  │ • progress      │  │ • unreadCount   │                                  │
│  │ • updateProgress│  │ • add()         │                                  │
│  │ • clearJob()    │  │ • markRead()    │                                  │
│  └─────────────────┘  └─────────────────┘                                  │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘
                                     │
                                     ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│                              LOCAL STATE                                     │
│                         (React useState/useReducer)                         │
├─────────────────────────────────────────────────────────────────────────────┤
│                                                                             │
│  • Form input values                                                        │
│  • Component-specific UI state (dropdowns open, tabs active)                │
│  • Temporary state that doesn't need to be shared                           │
│                                                                             │
└─────────────────────────────────────────────────────────────────────────────┘
```

### 22.3.2 Zustand Store Implementations

```typescript theme={null}
// stores/auth-store.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface User {
  id: string;
  email: string;
  name: string;
  role: 'super_admin' | 'agency_admin' | 'agency_member' | 'client';
  agencyId: string | null;
  clientId: string | null;
}

interface AuthState {
  user: User | null;
  accessToken: string | null;
  refreshToken: string | null;
  isAuthenticated: boolean;
  isLoading: boolean;
  
  // Actions
  setAuth: (user: User, accessToken: string, refreshToken: string) => void;
  setTokens: (accessToken: string, refreshToken: string) => void;
  logout: () => void;
  setLoading: (loading: boolean) => void;
}

export const useAuthStore = create<AuthState>()(
  persist(
    (set) => ({
      user: null,
      accessToken: null,
      refreshToken: null,
      isAuthenticated: false,
      isLoading: true,
      
      setAuth: (user, accessToken, refreshToken) =>
        set({
          user,
          accessToken,
          refreshToken,
          isAuthenticated: true,
          isLoading: false,
        }),
        
      setTokens: (accessToken, refreshToken) =>
        set({ accessToken, refreshToken }),
        
      logout: () =>
        set({
          user: null,
          accessToken: null,
          refreshToken: null,
          isAuthenticated: false,
          isLoading: false,
        }),
        
      setLoading: (isLoading) => set({ isLoading }),
    }),
    {
      name: 'auth-storage',
      partialize: (state) => ({
        accessToken: state.accessToken,
        refreshToken: state.refreshToken,
      }),
    }
  )
);
```

```typescript theme={null}
// stores/agency-store.ts
import { create } from 'zustand';

interface AgencyBranding {
  primaryColor: string;
  secondaryColor: string;
  accentColor: string;
  logoUrl: string | null;
  faviconUrl: string | null;
}

interface AgencyState {
  currentAgency: {
    id: string;
    name: string;
    slug: string;
    customDomain: string | null;
    plan: 'pro' | 'enterprise';
    branding: AgencyBranding;
  } | null;
  
  setAgency: (agency: AgencyState['currentAgency']) => void;
  updateBranding: (branding: Partial<AgencyBranding>) => void;
  clearAgency: () => void;
}

export const useAgencyStore = create<AgencyState>((set) => ({
  currentAgency: null,
  
  setAgency: (agency) => set({ currentAgency: agency }),
  
  updateBranding: (branding) =>
    set((state) => ({
      currentAgency: state.currentAgency
        ? {
            ...state.currentAgency,
            branding: { ...state.currentAgency.branding, ...branding },
          }
        : null,
    })),
    
  clearAgency: () => set({ currentAgency: null }),
}));
```

```typescript theme={null}
// stores/generation-store.ts
import { create } from 'zustand';

interface GenerationProgress {
  jobId: string;
  documentId: string | null;
  clientId: string;
  topic: string;
  status: 'queued' | 'processing' | 'completed' | 'failed';
  currentStep: string;
  stepNumber: number;
  totalSteps: number;
  progress: number;
  logs: Array<{
    timestamp: string;
    message: string;
    type: 'info' | 'success' | 'error';
  }>;
  error: string | null;
  startedAt: string;
  completedAt: string | null;
}

interface GenerationState {
  activeJobs: Map<string, GenerationProgress>;
  
  addJob: (job: GenerationProgress) => void;
  updateJob: (jobId: string, updates: Partial<GenerationProgress>) => void;
  addLog: (jobId: string, log: GenerationProgress['logs'][0]) => void;
  removeJob: (jobId: string) => void;
  clearCompletedJobs: () => void;
}

export const useGenerationStore = create<GenerationState>((set) => ({
  activeJobs: new Map(),
  
  addJob: (job) =>
    set((state) => {
      const newJobs = new Map(state.activeJobs);
      newJobs.set(job.jobId, job);
      return { activeJobs: newJobs };
    }),
    
  updateJob: (jobId, updates) =>
    set((state) => {
      const newJobs = new Map(state.activeJobs);
      const existing = newJobs.get(jobId);
      if (existing) {
        newJobs.set(jobId, { ...existing, ...updates });
      }
      return { activeJobs: newJobs };
    }),
    
  addLog: (jobId, log) =>
    set((state) => {
      const newJobs = new Map(state.activeJobs);
      const existing = newJobs.get(jobId);
      if (existing) {
        newJobs.set(jobId, {
          ...existing,
          logs: [...existing.logs, log],
        });
      }
      return { activeJobs: newJobs };
    }),
    
  removeJob: (jobId) =>
    set((state) => {
      const newJobs = new Map(state.activeJobs);
      newJobs.delete(jobId);
      return { activeJobs: newJobs };
    }),
    
  clearCompletedJobs: () =>
    set((state) => {
      const newJobs = new Map(state.activeJobs);
      for (const [jobId, job] of newJobs) {
        if (job.status === 'completed' || job.status === 'failed') {
          newJobs.delete(jobId);
        }
      }
      return { activeJobs: newJobs };
    }),
}));
```

```typescript theme={null}
// stores/ui-store.ts
import { create } from 'zustand';

interface UIState {
  sidebarOpen: boolean;
  sidebarCollapsed: boolean;
  activeModal: string | null;
  modalData: Record<string, unknown> | null;
  theme: 'light' | 'dark' | 'system';
  
  toggleSidebar: () => void;
  setSidebarOpen: (open: boolean) => void;
  toggleSidebarCollapsed: () => void;
  openModal: (modalId: string, data?: Record<string, unknown>) => void;
  closeModal: () => void;
  setTheme: (theme: UIState['theme']) => void;
}

export const useUIStore = create<UIState>((set) => ({
  sidebarOpen: true,
  sidebarCollapsed: false,
  activeModal: null,
  modalData: null,
  theme: 'system',
  
  toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
  setSidebarOpen: (open) => set({ sidebarOpen: open }),
  toggleSidebarCollapsed: () =>
    set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })),
  openModal: (modalId, data = null) =>
    set({ activeModal: modalId, modalData: data }),
  closeModal: () => set({ activeModal: null, modalData: null }),
  setTheme: (theme) => set({ theme }),
}));
```

## 22.4 API Client Layer

### 22.4.1 Axios Configuration

```typescript theme={null}
// lib/api/client.ts
import axios, { AxiosError, AxiosRequestConfig } from 'axios';
import { useAuthStore } from '@/stores/auth-store';

const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000';

// Create axios instance
export const apiClient = axios.create({
  baseURL: `${API_BASE_URL}/api/v1`,
  headers: {
    'Content-Type': 'application/json',
  },
  timeout: 30000, // 30 seconds
});

// Request interceptor - add auth token
apiClient.interceptors.request.use(
  (config) => {
    const { accessToken } = useAuthStore.getState();
    if (accessToken) {
      config.headers.Authorization = `Bearer ${accessToken}`;
    }
    return config;
  },
  (error) => Promise.reject(error)
);

// Response interceptor - handle token refresh
apiClient.interceptors.response.use(
  (response) => response,
  async (error: AxiosError) => {
    const originalRequest = error.config as AxiosRequestConfig & {
      _retry?: boolean;
    };
    
    // If 401 and not already retried
    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;
      
      const { refreshToken, setTokens, logout } = useAuthStore.getState();
      
      if (refreshToken) {
        try {
          // Attempt token refresh
          const response = await axios.post(`${API_BASE_URL}/api/v1/auth/refresh`, {
            refresh_token: refreshToken,
          });
          
          const { access_token, refresh_token } = response.data;
          setTokens(access_token, refresh_token);
          
          // Retry original request with new token
          if (originalRequest.headers) {
            originalRequest.headers.Authorization = `Bearer ${access_token}`;
          }
          return apiClient(originalRequest);
        } catch (refreshError) {
          // Refresh failed, logout user
          logout();
          window.location.href = '/login';
          return Promise.reject(refreshError);
        }
      }
    }
    
    return Promise.reject(error);
  }
);

// Generic request helper
export async function apiRequest<T>(config: AxiosRequestConfig): Promise<T> {
  const response = await apiClient(config);
  return response.data;
}
```

### 22.4.2 API Service Modules

```typescript theme={null}
// lib/api/clients.ts
import { apiRequest } from './client';
import type { Client, ClientCreate, ClientUpdate, PaginatedResponse } from '@/types';

export const clientsApi = {
  // List clients with pagination and filters
  list: async (params?: {
    page?: number;
    limit?: number;
    search?: string;
    industry?: string;
    status?: 'active' | 'inactive';
  }): Promise<PaginatedResponse<Client>> => {
    return apiRequest({
      method: 'GET',
      url: '/clients',
      params,
    });
  },
  
  // Get single client
  get: async (clientId: string): Promise<Client> => {
    return apiRequest({
      method: 'GET',
      url: `/clients/${clientId}`,
    });
  },
  
  // Create client
  create: async (data: ClientCreate): Promise<Client> => {
    return apiRequest({
      method: 'POST',
      url: '/clients',
      data,
    });
  },
  
  // Update client
  update: async (clientId: string, data: ClientUpdate): Promise<Client> => {
    return apiRequest({
      method: 'PATCH',
      url: `/clients/${clientId}`,
      data,
    });
  },
  
  // Delete client
  delete: async (clientId: string): Promise<void> => {
    return apiRequest({
      method: 'DELETE',
      url: `/clients/${clientId}`,
    });
  },
  
  // Upload client logo
  uploadLogo: async (clientId: string, file: File): Promise<{ url: string }> => {
    const formData = new FormData();
    formData.append('file', file);
    
    return apiRequest({
      method: 'POST',
      url: `/clients/${clientId}/logo`,
      data: formData,
      headers: {
        'Content-Type': 'multipart/form-data',
      },
    });
  },
  
  // Get client statistics
  getStats: async (clientId: string): Promise<{
    documentCount: number;
    scheduledCount: number;
    draftCount: number;
    distributedCount: number;
  }> => {
    return apiRequest({
      method: 'GET',
      url: `/clients/${clientId}/stats`,
    });
  },
};
```

```typescript theme={null}
// lib/api/generation.ts
import { apiRequest } from './client';
import type { GenerationRequest, GenerationJob, Document } from '@/types';

export const generationApi = {
  // Start content generation
  start: async (data: GenerationRequest): Promise<GenerationJob> => {
    return apiRequest({
      method: 'POST',
      url: '/generation/start',
      data,
    });
  },
  
  // Get job status
  getStatus: async (jobId: string): Promise<GenerationJob> => {
    return apiRequest({
      method: 'GET',
      url: `/generation/jobs/${jobId}`,
    });
  },
  
  // Cancel job
  cancel: async (jobId: string): Promise<void> => {
    return apiRequest({
      method: 'POST',
      url: `/generation/jobs/${jobId}/cancel`,
    });
  },
  
  // Retry failed job
  retry: async (jobId: string): Promise<GenerationJob> => {
    return apiRequest({
      method: 'POST',
      url: `/generation/jobs/${jobId}/retry`,
    });
  },
  
  // Get document after generation
  getDocument: async (documentId: string): Promise<Document> => {
    return apiRequest({
      method: 'GET',
      url: `/documents/${documentId}`,
    });
  },
};
```

## 22.5 TanStack Query Integration

### 22.5.1 Query Client Configuration

```typescript theme={null}
// lib/query-client.ts
import { QueryClient } from '@tanstack/react-query';

export const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5, // 5 minutes
      gcTime: 1000 * 60 * 30, // 30 minutes (formerly cacheTime)
      retry: 1,
      refetchOnWindowFocus: false,
    },
    mutations: {
      retry: 0,
    },
  },
});
```

### 22.5.2 Query Hooks

```typescript theme={null}
// hooks/queries/use-clients.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { clientsApi } from '@/lib/api/clients';
import type { Client, ClientCreate, ClientUpdate } from '@/types';
import { toast } from 'sonner';

// Query keys factory
export const clientKeys = {
  all: ['clients'] as const,
  lists: () => [...clientKeys.all, 'list'] as const,
  list: (filters: Record<string, unknown>) => [...clientKeys.lists(), filters] as const,
  details: () => [...clientKeys.all, 'detail'] as const,
  detail: (id: string) => [...clientKeys.details(), id] as const,
  stats: (id: string) => [...clientKeys.detail(id), 'stats'] as const,
};

// List clients
export function useClients(params?: {
  page?: number;
  limit?: number;
  search?: string;
  industry?: string;
}) {
  return useQuery({
    queryKey: clientKeys.list(params || {}),
    queryFn: () => clientsApi.list(params),
  });
}

// Get single client
export function useClient(clientId: string) {
  return useQuery({
    queryKey: clientKeys.detail(clientId),
    queryFn: () => clientsApi.get(clientId),
    enabled: !!clientId,
  });
}

// Get client stats
export function useClientStats(clientId: string) {
  return useQuery({
    queryKey: clientKeys.stats(clientId),
    queryFn: () => clientsApi.getStats(clientId),
    enabled: !!clientId,
  });
}

// Create client
export function useCreateClient() {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: (data: ClientCreate) => clientsApi.create(data),
    onSuccess: (newClient) => {
      // Invalidate list queries
      queryClient.invalidateQueries({ queryKey: clientKeys.lists() });
      toast.success(`Client "${newClient.companyName}" created successfully`);
    },
    onError: (error: Error) => {
      toast.error(`Failed to create client: ${error.message}`);
    },
  });
}

// Update client
export function useUpdateClient(clientId: string) {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: (data: ClientUpdate) => clientsApi.update(clientId, data),
    onSuccess: (updatedClient) => {
      // Update cache directly
      queryClient.setQueryData(clientKeys.detail(clientId), updatedClient);
      // Invalidate list queries
      queryClient.invalidateQueries({ queryKey: clientKeys.lists() });
      toast.success('Client updated successfully');
    },
    onError: (error: Error) => {
      toast.error(`Failed to update client: ${error.message}`);
    },
  });
}

// Delete client
export function useDeleteClient() {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: (clientId: string) => clientsApi.delete(clientId),
    onSuccess: (_, clientId) => {
      // Remove from cache
      queryClient.removeQueries({ queryKey: clientKeys.detail(clientId) });
      // Invalidate list queries
      queryClient.invalidateQueries({ queryKey: clientKeys.lists() });
      toast.success('Client deleted successfully');
    },
    onError: (error: Error) => {
      toast.error(`Failed to delete client: ${error.message}`);
    },
  });
}
```

```typescript theme={null}
// hooks/queries/use-documents.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { documentsApi } from '@/lib/api/documents';
import type { Document, DocumentFilters } from '@/types';
import { toast } from 'sonner';

export const documentKeys = {
  all: ['documents'] as const,
  lists: () => [...documentKeys.all, 'list'] as const,
  list: (filters: DocumentFilters) => [...documentKeys.lists(), filters] as const,
  details: () => [...documentKeys.all, 'detail'] as const,
  detail: (id: string) => [...documentKeys.details(), id] as const,
  byClient: (clientId: string) => [...documentKeys.all, 'client', clientId] as const,
};

// List documents with filters
export function useDocuments(filters: DocumentFilters) {
  return useQuery({
    queryKey: documentKeys.list(filters),
    queryFn: () => documentsApi.list(filters),
  });
}

// Get documents for a specific client
export function useClientDocuments(clientId: string, params?: { page?: number; limit?: number }) {
  return useQuery({
    queryKey: [...documentKeys.byClient(clientId), params],
    queryFn: () => documentsApi.list({ clientId, ...params }),
    enabled: !!clientId,
  });
}

// Get single document
export function useDocument(documentId: string) {
  return useQuery({
    queryKey: documentKeys.detail(documentId),
    queryFn: () => documentsApi.get(documentId),
    enabled: !!documentId,
  });
}

// Delete document
export function useDeleteDocument() {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: (documentId: string) => documentsApi.delete(documentId),
    onSuccess: (_, documentId) => {
      queryClient.removeQueries({ queryKey: documentKeys.detail(documentId) });
      queryClient.invalidateQueries({ queryKey: documentKeys.lists() });
      toast.success('Document deleted successfully');
    },
    onError: (error: Error) => {
      toast.error(`Failed to delete document: ${error.message}`);
    },
  });
}
```

## 22.6 WebSocket Integration

### 22.6.1 WebSocket Hook

```typescript theme={null}
// hooks/use-websocket.ts
import { useEffect, useRef, useCallback } from 'react';
import { io, Socket } from 'socket.io-client';
import { useAuthStore } from '@/stores/auth-store';

interface UseWebSocketOptions {
  onConnect?: () => void;
  onDisconnect?: () => void;
  onError?: (error: Error) => void;
}

export function useWebSocket(options: UseWebSocketOptions = {}) {
  const socketRef = useRef<Socket | null>(null);
  const { accessToken, isAuthenticated } = useAuthStore();
  
  useEffect(() => {
    if (!isAuthenticated || !accessToken) {
      return;
    }
    
    const socket = io(process.env.NEXT_PUBLIC_WS_URL || 'http://localhost:8000', {
      auth: {
        token: accessToken,
      },
      transports: ['websocket'],
      reconnection: true,
      reconnectionAttempts: 5,
      reconnectionDelay: 1000,
    });
    
    socket.on('connect', () => {
      console.log('WebSocket connected');
      options.onConnect?.();
    });
    
    socket.on('disconnect', () => {
      console.log('WebSocket disconnected');
      options.onDisconnect?.();
    });
    
    socket.on('connect_error', (error) => {
      console.error('WebSocket error:', error);
      options.onError?.(error);
    });
    
    socketRef.current = socket;
    
    return () => {
      socket.disconnect();
      socketRef.current = null;
    };
  }, [isAuthenticated, accessToken]);
  
  const subscribe = useCallback((event: string, handler: (data: unknown) => void) => {
    socketRef.current?.on(event, handler);
    return () => {
      socketRef.current?.off(event, handler);
    };
  }, []);
  
  const emit = useCallback((event: string, data: unknown) => {
    socketRef.current?.emit(event, data);
  }, []);
  
  return {
    socket: socketRef.current,
    subscribe,
    emit,
    isConnected: socketRef.current?.connected ?? false,
  };
}
```

### 22.6.2 Generation Progress Hook

```typescript theme={null}
// hooks/use-generation-progress.ts
import { useEffect } from 'react';
import { useWebSocket } from './use-websocket';
import { useGenerationStore } from '@/stores/generation-store';

interface GenerationEvent {
  job_id: string;
  document_id: string | null;
  status: 'queued' | 'processing' | 'completed' | 'failed';
  current_step: string;
  step_number: number;
  total_steps: number;
  progress: number;
  message?: string;
  error?: string;
}

export function useGenerationProgress(jobId?: string) {
  const { subscribe } = useWebSocket();
  const { updateJob, addLog } = useGenerationStore();
  
  useEffect(() => {
    // Subscribe to generation progress updates
    const unsubscribeProgress = subscribe('generation:progress', (data: GenerationEvent) => {
      if (jobId && data.job_id !== jobId) return;
      
      updateJob(data.job_id, {
        status: data.status,
        currentStep: data.current_step,
        stepNumber: data.step_number,
        totalSteps: data.total_steps,
        progress: data.progress,
        documentId: data.document_id,
      });
      
      if (data.message) {
        addLog(data.job_id, {
          timestamp: new Date().toISOString(),
          message: data.message,
          type: 'info',
        });
      }
    });
    
    // Subscribe to generation completion
    const unsubscribeComplete = subscribe('generation:complete', (data: GenerationEvent) => {
      if (jobId && data.job_id !== jobId) return;
      
      updateJob(data.job_id, {
        status: 'completed',
        progress: 100,
        documentId: data.document_id,
        completedAt: new Date().toISOString(),
      });
      
      addLog(data.job_id, {
        timestamp: new Date().toISOString(),
        message: 'Document generated successfully',
        type: 'success',
      });
    });
    
    // Subscribe to generation errors
    const unsubscribeError = subscribe('generation:error', (data: GenerationEvent) => {
      if (jobId && data.job_id !== jobId) return;
      
      updateJob(data.job_id, {
        status: 'failed',
        error: data.error,
      });
      
      addLog(data.job_id, {
        timestamp: new Date().toISOString(),
        message: data.error || 'Generation failed',
        type: 'error',
      });
    });
    
    return () => {
      unsubscribeProgress();
      unsubscribeComplete();
      unsubscribeError();
    };
  }, [jobId, subscribe, updateJob, addLog]);
}
```

## 22.7 Form Handling

### 22.7.1 Form Schema Definitions

```typescript theme={null}
// lib/validations/client.ts
import { z } from 'zod';

export const clientCreateSchema = z.object({
  companyName: z
    .string()
    .min(1, 'Company name is required')
    .max(100, 'Company name must be less than 100 characters'),
  industry: z.string().min(1, 'Industry is required'),
  website: z
    .string()
    .url('Please enter a valid URL')
    .optional()
    .or(z.literal('')),
  contactName: z.string().optional(),
  contactEmail: z
    .string()
    .email('Please enter a valid email')
    .optional()
    .or(z.literal('')),
  contactPhone: z.string().optional(),
  location: z.string().optional(),
  description: z.string().max(500, 'Description must be less than 500 characters').optional(),
  services: z.array(z.string()).default([]),
  defaultTone: z.enum(['professional', 'conversational', 'authoritative', 'friendly']).default('professional'),
  defaultTemplateCode: z.string().optional(),
});

export type ClientCreateInput = z.infer<typeof clientCreateSchema>;

export const clientUpdateSchema = clientCreateSchema.partial();
export type ClientUpdateInput = z.infer<typeof clientUpdateSchema>;
```

```typescript theme={null}
// lib/validations/generation.ts
import { z } from 'zod';

export const generationRequestSchema = z.object({
  clientId: z.string().uuid('Invalid client ID'),
  topic: z
    .string()
    .min(10, 'Topic must be at least 10 characters')
    .max(200, 'Topic must be less than 200 characters'),
  templateCode: z.string().min(1, 'Please select a template'),
  tone: z.enum(['professional', 'conversational', 'authoritative', 'friendly']),
  keywords: z.array(z.string()).max(10, 'Maximum 10 keywords allowed').default([]),
  services: z.array(z.string()).default([]),
  customDirection: z.string().max(1000, 'Custom direction must be less than 1000 characters').optional(),
  autoDistribute: z.boolean().default(false),
  distributionPlatforms: z.array(z.enum(['linkedin', 'facebook', 'twitter', 'google_business'])).default([]),
});

export type GenerationRequestInput = z.infer<typeof generationRequestSchema>;
```

### 22.7.2 Form Component Example

```typescript theme={null}
// components/clients/client-form.tsx
'use client';

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/navigation';
import { clientCreateSchema, type ClientCreateInput } from '@/lib/validations/client';
import { useCreateClient, useUpdateClient } from '@/hooks/queries/use-clients';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import {
  Form,
  FormControl,
  FormDescription,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from '@/components/ui/form';
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from '@/components/ui/select';
import type { Client } from '@/types';

const INDUSTRIES = [
  { value: 'technology', label: 'Technology' },
  { value: 'healthcare', label: 'Healthcare' },
  { value: 'finance', label: 'Finance' },
  { value: 'manufacturing', label: 'Manufacturing' },
  { value: 'retail', label: 'Retail' },
  { value: 'consulting', label: 'Consulting' },
  { value: 'other', label: 'Other' },
];

interface ClientFormProps {
  client?: Client;
  onSuccess?: () => void;
}

export function ClientForm({ client, onSuccess }: ClientFormProps) {
  const router = useRouter();
  const isEditing = !!client;
  
  const createClient = useCreateClient();
  const updateClient = useUpdateClient(client?.id ?? '');
  
  const form = useForm<ClientCreateInput>({
    resolver: zodResolver(clientCreateSchema),
    defaultValues: {
      companyName: client?.companyName ?? '',
      industry: client?.industry ?? '',
      website: client?.website ?? '',
      contactName: client?.contactName ?? '',
      contactEmail: client?.contactEmail ?? '',
      contactPhone: client?.contactPhone ?? '',
      location: client?.location ?? '',
      description: client?.description ?? '',
      services: client?.services ?? [],
      defaultTone: client?.defaults?.tone ?? 'professional',
      defaultTemplateCode: client?.defaults?.templateCode ?? '',
    },
  });
  
  const onSubmit = async (data: ClientCreateInput) => {
    try {
      if (isEditing) {
        await updateClient.mutateAsync(data);
      } else {
        await createClient.mutateAsync(data);
      }
      onSuccess?.();
      router.push('/clients');
    } catch (error) {
      // Error is handled by mutation onError
    }
  };
  
  const isLoading = createClient.isPending || updateClient.isPending;
  
  return (
    <Form {...form}>
      <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
        <FormField
          control={form.control}
          name="companyName"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Company Name *</FormLabel>
              <FormControl>
                <Input placeholder="Acme Corporation" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        
        <FormField
          control={form.control}
          name="industry"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Industry *</FormLabel>
              <Select onValueChange={field.onChange} defaultValue={field.value}>
                <FormControl>
                  <SelectTrigger>
                    <SelectValue placeholder="Select an industry" />
                  </SelectTrigger>
                </FormControl>
                <SelectContent>
                  {INDUSTRIES.map((industry) => (
                    <SelectItem key={industry.value} value={industry.value}>
                      {industry.label}
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>
              <FormMessage />
            </FormItem>
          )}
        />
        
        <FormField
          control={form.control}
          name="website"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Website</FormLabel>
              <FormControl>
                <Input placeholder="https://example.com" {...field} />
              </FormControl>
              <FormDescription>
                Company website URL
              </FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />
        
        <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
          <FormField
            control={form.control}
            name="contactName"
            render={({ field }) => (
              <FormItem>
                <FormLabel>Contact Name</FormLabel>
                <FormControl>
                  <Input placeholder="John Doe" {...field} />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />
          
          <FormField
            control={form.control}
            name="contactEmail"
            render={({ field }) => (
              <FormItem>
                <FormLabel>Contact Email</FormLabel>
                <FormControl>
                  <Input placeholder="john@example.com" type="email" {...field} />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />
        </div>
        
        <FormField
          control={form.control}
          name="description"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Description</FormLabel>
              <FormControl>
                <Textarea
                  placeholder="Brief description of the client's business..."
                  className="resize-none"
                  {...field}
                />
              </FormControl>
              <FormDescription>
                This helps AI generate more relevant content.
              </FormDescription>
              <FormMessage />
            </FormItem>
          )}
        />
        
        <div className="flex justify-end gap-4">
          <Button
            type="button"
            variant="outline"
            onClick={() => router.back()}
            disabled={isLoading}
          >
            Cancel
          </Button>
          <Button type="submit" disabled={isLoading}>
            {isLoading ? 'Saving...' : isEditing ? 'Update Client' : 'Create Client'}
          </Button>
        </div>
      </form>
    </Form>
  );
}
```

## 22.8 Authentication Flow (Frontend)

### 22.8.1 Auth Provider

```typescript theme={null}
// components/providers/auth-provider.tsx
'use client';

import { useEffect, ReactNode } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import { useAuthStore } from '@/stores/auth-store';
import { useAgencyStore } from '@/stores/agency-store';
import { authApi } from '@/lib/api/auth';
import { LoadingSpinner } from '@/components/shared/loading-spinner';

const PUBLIC_PATHS = ['/login', '/register', '/forgot-password', '/reset-password'];

interface AuthProviderProps {
  children: ReactNode;
}

export function AuthProvider({ children }: AuthProviderProps) {
  const router = useRouter();
  const pathname = usePathname();
  const { isAuthenticated, isLoading, setAuth, setLoading, logout } = useAuthStore();
  const { setAgency, clearAgency } = useAgencyStore();
  
  useEffect(() => {
    const initAuth = async () => {
      const { accessToken, refreshToken } = useAuthStore.getState();
      
      if (!accessToken || !refreshToken) {
        setLoading(false);
        return;
      }
      
      try {
        // Verify token and get user data
        const { user, agency } = await authApi.me();
        setAuth(user, accessToken, refreshToken);
        if (agency) {
          setAgency(agency);
        }
      } catch (error) {
        // Token invalid, clear auth
        logout();
        clearAgency();
      }
    };
    
    initAuth();
  }, []);
  
  useEffect(() => {
    if (isLoading) return;
    
    const isPublicPath = PUBLIC_PATHS.some((path) => pathname.startsWith(path));
    
    if (!isAuthenticated && !isPublicPath) {
      router.push('/login');
    }
    
    if (isAuthenticated && isPublicPath) {
      router.push('/dashboard');
    }
  }, [isAuthenticated, isLoading, pathname, router]);
  
  if (isLoading) {
    return (
      <div className="flex items-center justify-center min-h-screen">
        <LoadingSpinner size="lg" />
      </div>
    );
  }
  
  return <>{children}</>;
}
```

### 22.8.2 Login Page

```typescript theme={null}
// app/(auth)/login/page.tsx
'use client';

import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import Link from 'next/link';
import { useAuthStore } from '@/stores/auth-store';
import { useAgencyStore } from '@/stores/agency-store';
import { authApi } from '@/lib/api/auth';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from '@/components/ui/form';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Logo } from '@/components/shared/logo';

const loginSchema = z.object({
  email: z.string().email('Please enter a valid email'),
  password: z.string().min(1, 'Password is required'),
});

type LoginInput = z.infer<typeof loginSchema>;

export default function LoginPage() {
  const router = useRouter();
  const { setAuth } = useAuthStore();
  const { setAgency } = useAgencyStore();
  const [error, setError] = useState<string | null>(null);
  const [isLoading, setIsLoading] = useState(false);
  
  const form = useForm<LoginInput>({
    resolver: zodResolver(loginSchema),
    defaultValues: {
      email: '',
      password: '',
    },
  });
  
  const onSubmit = async (data: LoginInput) => {
    setError(null);
    setIsLoading(true);
    
    try {
      const response = await authApi.login(data.email, data.password);
      setAuth(response.user, response.access_token, response.refresh_token);
      
      if (response.agency) {
        setAgency(response.agency);
      }
      
      // Redirect based on role
      if (response.user.role === 'super_admin') {
        router.push('/admin');
      } else {
        router.push('/dashboard');
      }
    } catch (err: any) {
      setError(err.response?.data?.detail || 'Invalid email or password');
    } finally {
      setIsLoading(false);
    }
  };
  
  return (
    <div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
      <div className="max-w-md w-full space-y-8">
        <div className="text-center">
          <Logo className="mx-auto h-12 w-auto" />
          <h2 className="mt-6 text-3xl font-bold text-gray-900">
            Sign in to your account
          </h2>
          <p className="mt-2 text-sm text-gray-600">
            Or{' '}
            <Link href="/register" className="font-medium text-primary hover:underline">
              create a new account
            </Link>
          </p>
        </div>
        
        {error && (
          <Alert variant="destructive">
            <AlertDescription>{error}</AlertDescription>
          </Alert>
        )}
        
        <Form {...form}>
          <form onSubmit={form.handleSubmit(onSubmit)} className="mt-8 space-y-6">
            <FormField
              control={form.control}
              name="email"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Email address</FormLabel>
                  <FormControl>
                    <Input
                      type="email"
                      autoComplete="email"
                      placeholder="you@example.com"
                      {...field}
                    />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
            
            <FormField
              control={form.control}
              name="password"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Password</FormLabel>
                  <FormControl>
                    <Input
                      type="password"
                      autoComplete="current-password"
                      placeholder="••••••••"
                      {...field}
                    />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />
            
            <div className="flex items-center justify-between">
              <Link
                href="/forgot-password"
                className="text-sm font-medium text-primary hover:underline"
              >
                Forgot your password?
              </Link>
            </div>
            
            <Button type="submit" className="w-full" disabled={isLoading}>
              {isLoading ? 'Signing in...' : 'Sign in'}
            </Button>
          </form>
        </Form>
      </div>
    </div>
  );
}
```

## 22.9 Protected Route Components

### 22.9.1 Permission Guard

```typescript theme={null}
// components/guards/permission-guard.tsx
'use client';

import { ReactNode } from 'react';
import { useAuthStore } from '@/stores/auth-store';
import { usePermissions } from '@/hooks/use-permissions';
import { ForbiddenError } from '@/components/shared/error-state';

type Permission = 
  | 'clients:read' | 'clients:write' | 'clients:delete'
  | 'documents:read' | 'documents:write' | 'documents:delete'
  | 'schedule:read' | 'schedule:write'
  | 'team:read' | 'team:write'
  | 'settings:read' | 'settings:write'
  | 'admin:access';

interface PermissionGuardProps {
  children: ReactNode;
  permission: Permission | Permission[];
  fallback?: ReactNode;
}

export function PermissionGuard({
  children,
  permission,
  fallback = <ForbiddenError />,
}: PermissionGuardProps) {
  const { hasPermission, hasAnyPermission } = usePermissions();
  
  const permissions = Array.isArray(permission) ? permission : [permission];
  const hasAccess = hasAnyPermission(permissions);
  
  if (!hasAccess) {
    return <>{fallback}</>;
  }
  
  return <>{children}</>;
}
```

### 22.9.2 Role-Based Layout

```typescript theme={null}
// app/(dashboard)/layout.tsx
'use client';

import { ReactNode } from 'react';
import { useAuthStore } from '@/stores/auth-store';
import { Sidebar } from '@/components/layout/sidebar';
import { Header } from '@/components/layout/header';
import { MobileNav } from '@/components/layout/mobile-nav';
import { useUIStore } from '@/stores/ui-store';
import { cn } from '@/lib/utils';

interface DashboardLayoutProps {
  children: ReactNode;
}

export default function DashboardLayout({ children }: DashboardLayoutProps) {
  const { user } = useAuthStore();
  const { sidebarCollapsed } = useUIStore();
  
  // Redirect super admin to admin layout
  if (user?.role === 'super_admin') {
    return null; // Handled by middleware
  }
  
  return (
    <div className="min-h-screen bg-gray-50">
      {/* Desktop Sidebar */}
      <Sidebar className="hidden lg:flex" />
      
      {/* Main Content */}
      <div
        className={cn(
          'flex flex-col min-h-screen transition-all duration-200',
          sidebarCollapsed ? 'lg:pl-16' : 'lg:pl-64'
        )}
      >
        <Header />
        
        <main className="flex-1 p-4 md:p-6 lg:p-8">
          <div className="mx-auto max-w-7xl">
            {children}
          </div>
        </main>
      </div>
      
      {/* Mobile Bottom Navigation */}
      <MobileNav className="lg:hidden" />
    </div>
  );
}
```

## 22.10 TypeScript Type Definitions

### 22.10.1 Core Types

```typescript theme={null}
// types/index.ts

// User types
export interface User {
  id: string;
  email: string;
  name: string;
  role: UserRole;
  agencyId: string | null;
  clientId: string | null;
  createdAt: string;
  updatedAt: string;
}

export type UserRole = 'super_admin' | 'agency_admin' | 'agency_member' | 'client';

// Agency types
export interface Agency {
  id: string;
  name: string;
  slug: string;
  plan: 'pro' | 'enterprise';
  customDomain: string | null;
  website: string | null;
  branding: AgencyBranding;
  settings: AgencySettings;
  createdAt: string;
  updatedAt: string;
}

export interface AgencyBranding {
  primaryColor: string;
  secondaryColor: string;
  accentColor: string;
  logoUrl: string | null;
  logoLightUrl: string | null;
  faviconUrl: string | null;
  colorMode: 'light' | 'dark';
}

export interface AgencySettings {
  defaultTimezone: string;
  footerText: string;
  notificationEmail: string | null;
}

// Client types
export interface Client {
  id: string;
  agencyId: string;
  companyName: string;
  slug: string;
  industry: string;
  website: string | null;
  contactName: string | null;
  contactEmail: string | null;
  contactPhone: string | null;
  location: string | null;
  description: string | null;
  logoUrl: string | null;
  services: string[];
  defaults: ClientDefaults;
  isActive: boolean;
  createdAt: string;
  updatedAt: string;
}

export interface ClientDefaults {
  tone: ToneType;
  templateCode: string | null;
  keywords: string[];
}

export type ToneType = 'professional' | 'conversational' | 'authoritative' | 'friendly';

// Document types
export interface Document {
  id: string;
  clientId: string;
  templateCode: string;
  title: string;
  subtitle: string | null;
  topic: string;
  status: DocumentStatus;
  pdfUrl: string | null;
  coverImageUrl: string | null;
  pageCount: number | null;
  wordCount: number | null;
  sourceCount: number | null;
  generationDuration: number | null;
  contentJson: DocumentContent | null;
  distribution: DistributionRecord[];
  expiresAt: string;
  createdAt: string;
  updatedAt: string;
}

export type DocumentStatus = 'draft' | 'generating' | 'ready' | 'distributed' | 'failed' | 'expired';

export interface DocumentContent {
  sections: DocumentSection[];
  statistics: DocumentStatistic[];
  charts: DocumentChart[];
  sources: DocumentSource[];
}

export interface DocumentSection {
  type: 'heading' | 'paragraph' | 'list' | 'quote' | 'callout';
  content: string;
  level?: number;
  items?: string[];
  attribution?: string;
  calloutType?: 'tip' | 'warning' | 'note' | 'example';
}

export interface DocumentStatistic {
  value: string;
  label: string;
  source: string;
  context?: string;
}

export interface DocumentChart {
  type: 'bar' | 'line' | 'donut';
  title: string;
  data: Record<string, unknown>;
  source: string;
}

export interface DocumentSource {
  title: string;
  url: string;
  domain: string;
  accessedAt: string;
}

export interface DistributionRecord {
  platform: DistributionPlatform;
  status: 'pending' | 'success' | 'failed';
  postUrl: string | null;
  distributedAt: string | null;
  error: string | null;
}

export type DistributionPlatform = 'linkedin' | 'facebook' | 'twitter' | 'google_business';

// Template types
export interface Template {
  id: string;
  code: string;
  name: string;
  description: string;
  previewUrl: string;
  isActive: boolean;
  isPremium: boolean;
  createdAt: string;
  updatedAt: string;
}

// Schedule types
export interface ScheduledContent {
  id: string;
  clientId: string;
  topic: string;
  templateCode: string;
  tone: ToneType;
  keywords: string[];
  services: string[];
  customDirection: string | null;
  scheduledFor: string;
  status: ScheduleStatus;
  documentId: string | null;
  autoDistribute: boolean;
  distributionPlatforms: DistributionPlatform[];
  createdAt: string;
  updatedAt: string;
}

export type ScheduleStatus = 'pending' | 'processing' | 'completed' | 'failed' | 'canceled';

// Generation types
export interface GenerationJob {
  id: string;
  documentId: string | null;
  clientId: string;
  status: GenerationStatus;
  currentStep: string;
  stepNumber: number;
  totalSteps: number;
  progress: number;
  error: string | null;
  startedAt: string;
  completedAt: string | null;
}

export type GenerationStatus = 'queued' | 'processing' | 'completed' | 'failed';

// API response types
export interface PaginatedResponse<T> {
  items: T[];
  total: number;
  page: number;
  limit: number;
  totalPages: number;
}

export interface ApiError {
  code: string;
  message: string;
  details?: Record<string, unknown>;
}
```

## 22.11 Environment Configuration

### 22.11.1 Environment Variables

```bash theme={null}
# .env.example (Frontend)

# API Configuration
NEXT_PUBLIC_API_URL=http://localhost:8000
NEXT_PUBLIC_WS_URL=http://localhost:8000

# App Configuration
NEXT_PUBLIC_APP_NAME="Content Strategist"
NEXT_PUBLIC_APP_URL=http://localhost:3000

# Feature Flags
NEXT_PUBLIC_ENABLE_ANALYTICS=false
NEXT_PUBLIC_ENABLE_DEMO_MODE=false

# Third-party Services (client-side safe)
NEXT_PUBLIC_SENTRY_DSN=

# Build Configuration
NEXT_PUBLIC_BUILD_ID=
```

### 22.11.2 Next.js Configuration

```javascript theme={null}
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  
  // Image optimization
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: '*.amazonaws.com',
      },
      {
        protocol: 'https',
        hostname: 'authapi.net',
      },
    ],
  },
  
  // Environment variables validation
  env: {
    NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
  },
  
  // Redirects
  async redirects() {
    return [
      {
        source: '/',
        destination: '/dashboard',
        permanent: false,
      },
    ];
  },
  
  // Headers for security
  async headers() {
    return [
      {
        source: '/:path*',
        headers: [
          {
            key: 'X-Frame-Options',
            value: 'DENY',
          },
          {
            key: 'X-Content-Type-Options',
            value: 'nosniff',
          },
          {
            key: 'Referrer-Policy',
            value: 'origin-when-cross-origin',
          },
        ],
      },
    ];
  },
};

module.exports = nextConfig;
```

## 22.12 Package.json Dependencies

```json theme={null}
{
  "name": "content-strategist-frontend",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",
    "lint:fix": "next lint --fix",
    "type-check": "tsc --noEmit",
    "format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css,md}\"",
    "test": "jest",
    "test:watch": "jest --watch",
    "test:coverage": "jest --coverage"
  },
  "dependencies": {
    "next": "^14.1.0",
    "react": "^18.2.0",
    "react-dom": "^18.2.0",
    
    "@tanstack/react-query": "^5.17.0",
    "@tanstack/react-table": "^8.11.0",
    "zustand": "^4.5.0",
    
    "axios": "^1.6.5",
    "socket.io-client": "^4.7.4",
    
    "react-hook-form": "^7.49.3",
    "@hookform/resolvers": "^3.3.4",
    "zod": "^3.22.4",
    
    "@radix-ui/react-alert-dialog": "^1.0.5",
    "@radix-ui/react-avatar": "^1.0.4",
    "@radix-ui/react-checkbox": "^1.0.4",
    "@radix-ui/react-dialog": "^1.0.5",
    "@radix-ui/react-dropdown-menu": "^2.0.6",
    "@radix-ui/react-label": "^2.0.2",
    "@radix-ui/react-popover": "^1.0.7",
    "@radix-ui/react-progress": "^1.0.3",
    "@radix-ui/react-scroll-area": "^1.0.5",
    "@radix-ui/react-select": "^2.0.0",
    "@radix-ui/react-separator": "^1.0.3",
    "@radix-ui/react-slot": "^1.0.2",
    "@radix-ui/react-switch": "^1.0.3",
    "@radix-ui/react-tabs": "^1.0.4",
    "@radix-ui/react-tooltip": "^1.0.7",
    
    "class-variance-authority": "^0.7.0",
    "clsx": "^2.1.0",
    "tailwind-merge": "^2.2.0",
    "tailwindcss-animate": "^1.0.7",
    
    "lucide-react": "^0.312.0",
    "recharts": "^2.10.4",
    "react-pdf": "^7.7.0",
    "react-dropzone": "^14.2.3",
    "sonner": "^1.3.1",
    "framer-motion": "^10.18.0",
    
    "date-fns": "^3.2.0",
    "cmdk": "^0.2.0"
  },
  "devDependencies": {
    "@types/node": "^20.11.0",
    "@types/react": "^18.2.47",
    "@types/react-dom": "^18.2.18",
    "typescript": "^5.3.3",
    
    "tailwindcss": "^3.4.1",
    "postcss": "^8.4.33",
    "autoprefixer": "^10.4.17",
    
    "eslint": "^8.56.0",
    "eslint-config-next": "^14.1.0",
    "@typescript-eslint/eslint-plugin": "^6.19.0",
    "@typescript-eslint/parser": "^6.19.0",
    
    "prettier": "^3.2.4",
    "prettier-plugin-tailwindcss": "^0.5.11",
    
    "jest": "^29.7.0",
    "@testing-library/react": "^14.1.2",
    "@testing-library/jest-dom": "^6.2.0"
  }
}
```

***
