Skip to main content
Normalized for Mintlify from knowledge-base/aiconnected-apps-and-modules/modules/aiConnected-paper/paper-developer-prd.mdx.

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
  2. Technology Stack
  3. System Architecture
  4. Database Design
  5. Authentication & Authorization
  6. API Design
  7. Content Generation Pipeline
  8. PDF Generation System
  9. Distribution System
  10. File Storage System
  11. White-Label System
  12. CSV Import System
  13. Scheduled Tasks
  14. WebSocket Real-Time Updates
  15. Error Handling
  16. Rate Limiting
  17. Logging & Monitoring
  18. Security Considerations
  19. Environment Configuration
  20. Deployment
  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

AspectOur ApproachCompetitor Approach
Research DepthDeep, scope-dependent research (20-500 sources)Shallow blog scraping (3-5 sources)
Output QualityConsulting-firm quality PDFsPlain text or basic formatting
DesignDynamic charts, callout boxes, professional typographyGeneric templates
White-LabelComplete branding control including custom domainsLogo swap only
ScaleProgrammatic generation via CSVManual UI only

1.3 User Types and Descriptions

User TypeDescriptionPrimary ActionsAccess Method
Super AdminOxford Pierpont staff managing the platformManage agencies, templates, plans, system settingsDirect login to admin panel
Agency AdminAgency owner/manager with full agency accessManage clients, team, settings, view all content, configure brandingLogin via agency domain
Agency MemberAgency staff with limited permissionsGenerate content, review, distributeLogin via agency domain
ClientEnd customer viewing their contentView 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

ComponentTechnologyVersionPurposeWhy This Choice
LanguagePython3.11+Primary backend languageBest AI/ML ecosystem, FastAPI compatibility
FrameworkFastAPI0.109+REST API frameworkAsync support, automatic OpenAPI docs, type hints
ORMSQLAlchemy2.0+Database ORMIndustry standard, excellent PostgreSQL support
MigrationsAlembic1.13+Schema migrationsNative SQLAlchemy integration
Task QueueCelery5.3+Async job processingBattle-tested, Redis integration
Message BrokerRedis7+Celery broker + cachingFast, reliable, multi-purpose
DatabasePostgreSQL15+Primary data storeJSON support, reliability, performance
PDF EngineWeasyPrint60+HTML to PDF conversionCSS3 support, no external dependencies
HTTP Clienthttpx0.26+External API callsAsync support, modern API
ValidationPydantic2.5+Data validationNative FastAPI integration
Authpython-jose3.3+JWT handlingWell-maintained, full JWT support
Passwordspasslib[bcrypt]1.7+Password hashingIndustry standard bcrypt
WebSocketswebsockets12+Real-time updatesFastAPI native support

2.2 External Services

ServicePurposeRequiredFallback
Anthropic Claude APIContent generation, research synthesisYesNone (core functionality)
Freepik APIStock images for coversYesSolid color covers
LinkedIn APIContent distributionOptionalManual download/upload
Facebook Graph APIContent distributionOptionalManual download/upload
Twitter/X APIContent distributionOptionalManual download/upload
Google Business APIContent distributionOptionalManual download/upload

2.3 Complete Python Dependencies

# 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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

# 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

# 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

# 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

# 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

# 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

# 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

// 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:
{
    "email": "user@agency.com",
    "password": "securepassword123"
}
Response (200 OK):
{
    "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):
{
    "success": true,
    "data": {
        "access_token": "eyJhbGciOiJIUzI1NiIs...",
        "token_type": "bearer",
        "expires_in": 900
    }
}

POST /api/v1/auth/logout

Logout and invalidate tokens. Response (200 OK):
{
    "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):
{
    "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:
{
    "email": "user@agency.com"
}
Response (200 OK):
{
    "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:
{
    "token": "reset_token_from_email",
    "password": "newSecurePassword123",
    "password_confirmation": "newSecurePassword123"
}
Response (200 OK):
{
    "success": true,
    "data": {
        "message": "Password successfully reset"
    }
}

6.3 Client Management Endpoints

GET /api/v1/clients

List all clients for the agency. Query Parameters:
ParameterTypeDefaultDescription
pageinteger1Page number
per_pageinteger20Items per page (max 100)
searchstringSearch in company_name, contact_name
industrystringFilter by industry
is_activebooleantrueFilter by active status
sortstringcreated_atSort field
orderstringdescSort order (asc/desc)
Response (200 OK):
{
    "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:
{
    "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):
{
    "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):
{
    "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:
{
    "company_name": "Acme Corporation",
    "contact_name": "Jane Smith-Wilson",
    "industry": "Technology & Software",
    ...
}
Response (200 OK):
{
    "success": true,
    "data": {
        "id": "770e8400-e29b-41d4-a716-446655440002",
        ...
    }
}

PUT /api/v1/clients/{client_id}/branding

Update client branding (logos, colors). Request:
{
    "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
Upload client logo. Request: multipart/form-data
FieldTypeDescription
filefileImage file (JPG, PNG, WebP)
typestringLogo type: horizontal, vertical, round
Response (200 OK):
{
    "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):
{
    "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:
{
    "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):
{
    "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:
{
    "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:
{
    "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):
{
    "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):
{
    "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:
{
    "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):
{
    "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:
ParameterTypeDefaultDescription
pageinteger1Page number
per_pageinteger20Items per page
statusstringFilter by status (pending, generating, ready, distributed, failed)
searchstringSearch in title, topic
date_fromstringFilter by created_at >= date
date_tostringFilter by created_at <= date
sortstringcreated_atSort field
orderstringdescSort order
Response (200 OK):
{
    "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:
ParameterTypeDefaultDescription
pageinteger1Page number
per_pageinteger50Items per page
statusstringFilter: pending, processing, completed, failed, canceled
date_fromdateFilter by scheduled_date >=
date_todateFilter by scheduled_date <=
sortstringscheduled_atSort field
orderstringascSort order
Response (200 OK):
{
    "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:
{
    "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):
{
    "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
FieldTypeDescription
filefileCSV file
timezonestringDefault timezone for rows without timezone
dry_runbooleanIf true, validate only without creating
CSV Format:
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:
{
    "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:
{
    "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:
{
    "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):
{
    "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):
{
    "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):
{
    "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):
{
    "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):
{
    "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:
{
    "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:
{
    "color_mode": "dark",
    "color_accent_1": "#2563eb",
    "color_accent_2": "#d97706",
    "color_accent_3": "#059669"
}
Response (200 OK): Updated branding object
Upload agency logo. Request: multipart/form-data
FieldTypeDescription
filefileImage file (JPG, PNG, WebP, SVG)
typestringLogo type: horizontal, vertical, round, favicon
Response (200 OK):
{
    "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:
{
    "anthropic_api_key": "sk-ant-api03-xxxxxxxxxxxxx",
    "freepik_api_key": "fpk_xxxxxxxxxxxxx"
}
Response (200 OK):
{
    "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:
{
    "provider": "anthropic",
    "api_key": "sk-ant-api03-xxxxxxxxxxxxx"
}
Response (200 OK):
{
    "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):
{
    "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:
{
    "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):
{
    "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):
{
    "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:
{
    "domain": "content.acmeagency.com"
}
Response (200 OK):
{
    "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):
{
    "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):
{
    "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:
{
    "email": "newmember@acmeagency.com",
    "first_name": "Bob",
    "last_name": "Johnson",
    "role": "agency_member",
    "job_title": "Account Executive"
}
Response (201 Created):
{
    "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:
{
    "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):
{
    "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:
ParameterTypeDescription
pageintegerPage number
per_pageintegerItems per page
searchstringSearch in name
planstringFilter by plan name
statusstringFilter by subscription_status
sortstringSort field
orderstringSort order
Response (200 OK):
{
    "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:
{
    "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:
{
    "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:
{
    "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:
{
    "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):
{
    "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

StepIDDescriptionWeightTypical Duration
1topic_analysisAnalyze topic and determine scope5%5-10s
2keyword_researchIdentify relevant keywords5%5-10s
3web_researchDeep research from multiple sources30%30-120s
4industry_analysisAnalyze industry-specific reports10%10-20s
5outline_creationCreate content structure5%5-10s
6content_writingGenerate all written content25%30-60s
7statistics_integrationAdd statistics and data5%5-10s
8chart_generationCreate data visualizations5%10-20s
9cover_imageSelect/generate cover image3%5-15s
10template_applicationApply design template4%5-10s
11pdf_renderingRender final PDF2%5-15s
12quality_reviewFinal quality check1%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:
{
    "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:
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:
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:
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:
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:
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.
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.
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.
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.
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.
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.
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.
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.
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.
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

# 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

<!-- 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

<!-- 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

# 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

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

PlatformPost TypeContentLinkImage
LinkedInArticle/PostSummary textPDF URLCover image
FacebookPage PostSummary textPDF URLCover image
Twitter/XTweet + ThreadSummary + key pointsPDF URLCover image
Google BusinessPostSummary textPDF URLCover 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

# 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

# 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

# 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

# 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

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

TypeExamplePlanConfiguration
Default Subdomainacme.contentstrategist.comAllAutomatic from slug
Unbranded File Hostauthapi.net/files/acme/...AllSystem default
Custom Domaincontent.acmeagency.comEnterpriseDNS + SSL setup

11.1.2 Domain Resolution Middleware

# 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

# 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

# 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

# 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

# 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

# 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

# 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

# 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

# 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

# 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

# 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

# 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

# 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

AreaImplementation
AuthenticationJWT with short expiry (15 min), refresh tokens in HTTP-only cookies
Password Storagebcrypt with cost factor 12
API KeysSHA-256 hashed, prefixed for identification
Sensitive DataEncrypted at rest (Fernet symmetric encryption)
SQL InjectionSQLAlchemy ORM with parameterized queries
XSSPydantic validation, proper escaping in templates
CSRFSameSite cookies, custom headers for API
Rate LimitingRedis-based sliding window
Input ValidationPydantic schemas for all inputs
File UploadsType validation, size limits, isolated storage

18.2 Encryption Service

# 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

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

# 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

# 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

# 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

CategoryDescriptionTarget Coverage
Unit TestsIndividual functions/methods80%
Integration TestsAPI endpoints, database70%
E2E TestsFull generation flowCritical paths

21.2 Test Configuration

# 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

# 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

MethodEndpointDescription
POST/api/v1/auth/loginLogin
POST/api/v1/auth/refreshRefresh token
GET/api/v1/auth/meCurrent user
GET/api/v1/clientsList clients
POST/api/v1/clientsCreate client
GET/api/v1/clients/{id}Get client
PUT/api/v1/clients/{id}Update client
POST/api/v1/clients/{id}/documents/generateStart generation
GET/api/v1/documents/{id}Get document
GET/api/v1/documents/{id}/statusGet generation status
POST/api/v1/documents/{id}/distributeDistribute document
GET/api/v1/clients/{id}/scheduleList scheduled content
POST/api/v1/clients/{id}/schedule/importImport CSV
GET/api/v1/templatesList templates
GET/api/v1/agencyGet agency settings
PUT/api/v1/agencyUpdate agency

Document Version History

VersionDateAuthorChanges
2.0Jan 2, 2026ClaudeComplete comprehensive rewrite

END OF DEVELOPER PRD

22. Frontend Architecture

22.1 Frontend Technology Stack

ComponentTechnologyVersionPurpose
FrameworkNext.js14+React framework with App Router
LanguageTypeScript5.3+Type-safe JavaScript
StylingTailwind CSS3.4+Utility-first CSS
Componentsshadcn/uilatestAccessible component primitives
StateZustand4.5+Lightweight state management
Data FetchingTanStack Query5+Server state management
FormsReact Hook Form7.49+Performant form handling
ValidationZod3.22+Schema validation
HTTP ClientAxios1.6+API requests
WebSocketsocket.io-client4.7+Real-time updates
ChartsRecharts2.10+Data visualization
TablesTanStack Table8.11+Headless table logic
Date Handlingdate-fns3.0+Date utilities
IconsLucide React0.300+Icon library
PDF Previewreact-pdf7.7+PDF rendering
File Uploadreact-dropzone14.2+Drag-and-drop uploads
ToastSonner1.3+Toast notifications
AnimationsFramer Motion10.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

// 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,
      }),
    }
  )
);
// 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 }),
}));
// 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 };
    }),
}));
// 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

// 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

// 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`,
    });
  },
};
// 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

// 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

// 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}`);
    },
  });
}
// 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

// 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

// 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

// 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>;
// 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

// 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

// 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

// 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

// 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

// 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

// 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

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

// 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

{
  "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"
  }
}

Last modified on April 18, 2026