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.0Last Updated: January 2, 2026
Target Platform: Dokploy on DigitalOcean
Primary Developer Tool: Claude Code
Estimated Build Time: 1 Weekend (Focused)
Table of Contents
- Project Overview
- Technology Stack
- System Architecture
- Database Design
- Authentication & Authorization
- API Design
- Content Generation Pipeline
- PDF Generation System
- Distribution System
- File Storage System
- White-Label System
- CSV Import System
- Scheduled Tasks
- WebSocket Real-Time Updates
- Error Handling
- Rate Limiting
- Logging & Monitoring
- Security Considerations
- Environment Configuration
- Deployment
- Testing Requirements
1. Project Overview
1.1 Purpose
Content Strategist AI is a white-label SaaS platform that generates professional thought leadership content for marketing agencies and their clients. The platform produces executive-quality PDF documents with original research, statistics, data visualizations, and professional design.1.2 Key Differentiators
| Aspect | Our Approach | Competitor Approach |
|---|---|---|
| Research Depth | Deep, scope-dependent research (20-500 sources) | Shallow blog scraping (3-5 sources) |
| Output Quality | Consulting-firm quality PDFs | Plain text or basic formatting |
| Design | Dynamic charts, callout boxes, professional typography | Generic templates |
| White-Label | Complete branding control including custom domains | Logo swap only |
| Scale | Programmatic generation via CSV | Manual UI only |
1.3 User Types and Descriptions
| User Type | Description | Primary Actions | Access Method |
|---|---|---|---|
| Super Admin | Oxford Pierpont staff managing the platform | Manage agencies, templates, plans, system settings | Direct login to admin panel |
| Agency Admin | Agency owner/manager with full agency access | Manage clients, team, settings, view all content, configure branding | Login via agency domain |
| Agency Member | Agency staff with limited permissions | Generate content, review, distribute | Login via agency domain |
| Client | End customer viewing their content | View their content, request generation (limited) | Login via agency domain |
1.4 Business Rules
Seat Limits
- Agencies cannot exceed their plan’s seat (client) limit
- Attempting to add a client beyond the limit returns an error
- Deactivated clients do not count toward the limit
- Reactivating a client checks the limit before allowing
Template Access
- Pro plans: Limited to 5 templates (assigned by Super Admin)
- Enterprise plans: Access to all templates
- New templates can be published by Super Admin
- Agencies cannot access templates not assigned to them
API Usage
- Pro plans: BYOK (Bring Your Own Key) - agency provides their own Anthropic/Freepik keys
- Enterprise plans: Included credits ($1,000/month worth)
- Usage is tracked per document generation
- Enterprise agencies receive warnings at 80% usage
Document Retention
- All documents expire after 3 years from creation
- Expired documents are soft-deleted first, then hard-deleted after 30 days
- Agencies can export their data before expiration
- PDFs are removed from storage when documents are hard-deleted
Custom Domains
- Only available on Enterprise plans
- Requires DNS verification (TXT record)
- SSL certificates auto-provisioned via Let’s Encrypt
- One custom domain per agency
Image Upload
- Client-uploaded cover images only available on Enterprise plans
- Pro plans use Freepik stock images only
- Uploaded images stored for document retention period
- Maximum image size: 10MB
- Supported formats: JPG, PNG, WebP
2. Technology Stack
2.1 Core Technologies
| Component | Technology | Version | Purpose | Why This Choice |
|---|---|---|---|---|
| Language | Python | 3.11+ | Primary backend language | Best AI/ML ecosystem, FastAPI compatibility |
| Framework | FastAPI | 0.109+ | REST API framework | Async support, automatic OpenAPI docs, type hints |
| ORM | SQLAlchemy | 2.0+ | Database ORM | Industry standard, excellent PostgreSQL support |
| Migrations | Alembic | 1.13+ | Schema migrations | Native SQLAlchemy integration |
| Task Queue | Celery | 5.3+ | Async job processing | Battle-tested, Redis integration |
| Message Broker | Redis | 7+ | Celery broker + caching | Fast, reliable, multi-purpose |
| Database | PostgreSQL | 15+ | Primary data store | JSON support, reliability, performance |
| PDF Engine | WeasyPrint | 60+ | HTML to PDF conversion | CSS3 support, no external dependencies |
| HTTP Client | httpx | 0.26+ | External API calls | Async support, modern API |
| Validation | Pydantic | 2.5+ | Data validation | Native FastAPI integration |
| Auth | python-jose | 3.3+ | JWT handling | Well-maintained, full JWT support |
| Passwords | passlib[bcrypt] | 1.7+ | Password hashing | Industry standard bcrypt |
| WebSockets | websockets | 12+ | Real-time updates | FastAPI native support |
2.2 External Services
| Service | Purpose | Required | Fallback |
|---|---|---|---|
| Anthropic Claude API | Content generation, research synthesis | Yes | None (core functionality) |
| Freepik API | Stock images for covers | Yes | Solid color covers |
| LinkedIn API | Content distribution | Optional | Manual download/upload |
| Facebook Graph API | Content distribution | Optional | Manual download/upload |
| Twitter/X API | Content distribution | Optional | Manual download/upload |
| Google Business API | Content distribution | Optional | Manual download/upload |
2.3 Complete Python Dependencies
# 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"
}
{
"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: refresh_token=eyJ...; HttpOnly; Secure; SameSite=Lax; Path=/api/v1/auth; Max-Age=604800
401 Unauthorized: Invalid credentials403 Forbidden: Account locked or inactive422 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: 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"
}
{
"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"
}
{
"success": true,
"data": {
"message": "Password successfully reset"
}
}
6.3 Client Management Endpoints
GET /api/v1/clients
List all clients for the agency. Query Parameters:| Parameter | Type | Default | Description |
|---|---|---|---|
| page | integer | 1 | Page number |
| per_page | integer | 20 | Items per page (max 100) |
| search | string | Search in company_name, contact_name | |
| industry | string | Filter by industry | |
| is_active | boolean | true | Filter by active status |
| sort | string | created_at | Sort field |
| order | string | desc | Sort order (asc/desc) |
{
"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"
}
{
"success": true,
"data": {
"id": "880e8400-e29b-41d4-a716-446655440003",
"company_name": "NewCo Industries",
"company_slug": "newco-industries",
...
}
}
400 Bad Request: Seat limit exceeded422 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",
...
}
{
"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."
}
POST /api/v1/clients/{client_id}/branding/logo
Upload client logo. Request:multipart/form-data
| Field | Type | Description |
|---|---|---|
| file | file | Image file (JPG, PNG, WebP) |
| type | string | Logo type: horizontal, vertical, round |
{
"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
}
}
{
"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"
}
}
{
"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 headersContent-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"
}
{
"success": true,
"data": {
"distribution_id": "dist_cc0e8400",
"status": "processing",
"channels": {
"linkedin": {"status": "queued"},
"facebook": {"status": "queued"}
},
"polling_url": "/api/v1/documents/990e8400/distribution/dist_cc0e8400"
}
}
GET /api/v1/clients/{client_id}/documents
List documents for a client. Query Parameters:| Parameter | Type | Default | Description |
|---|---|---|---|
| page | integer | 1 | Page number |
| per_page | integer | 20 | Items per page |
| status | string | Filter by status (pending, generating, ready, distributed, failed) | |
| search | string | Search in title, topic | |
| date_from | string | Filter by created_at >= date | |
| date_to | string | Filter by created_at <= date | |
| sort | string | created_at | Sort field |
| order | string | desc | Sort order |
{
"success": true,
"data": [
{
"id": "990e8400-e29b-41d4-a716-446655440004",
"title": "AI Implementation: A Strategic Guide",
"topic": "AI Implementation Strategies",
"status": "distributed",
"template": {
"code": "EXEC_01",
"name": "Executive Professional"
},
"pdf_url": "https://storage.../990e8400.pdf",
"cover_image_url": "https://storage.../990e8400-cover.jpg",
"page_count": 8,
"distribution": {
"distributed_at": "2026-01-02T11:00:00Z",
"channels": ["linkedin", "facebook"]
},
"created_at": "2026-01-02T10:30:00Z",
"created_by": {
"id": "550e8400",
"name": "John Doe"
}
}
],
"pagination": { ... }
}
6.5 Schedule Management Endpoints
GET /api/v1/clients/{client_id}/schedule
List scheduled content for a client. Query Parameters:| Parameter | Type | Default | Description |
|---|---|---|---|
| page | integer | 1 | Page number |
| per_page | integer | 50 | Items per page |
| status | string | Filter: pending, processing, completed, failed, canceled | |
| date_from | date | Filter by scheduled_date >= | |
| date_to | date | Filter by scheduled_date <= | |
| sort | string | scheduled_at | Sort field |
| order | string | asc | Sort order |
{
"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
}
}
{
"success": true,
"data": {
"id": "sch_ff0e8400-e29b-41d4-a716-446655440008",
"scheduled_date": "2026-01-20",
"scheduled_time": "10:00:00",
"scheduled_at": "2026-01-20T15:00:00Z",
"status": "pending",
...
}
}
POST /api/v1/clients/{client_id}/schedule/import
Import scheduled content from CSV. Request:multipart/form-data
| Field | Type | Description |
|---|---|---|
| file | file | CSV file |
| timezone | string | Default timezone for rows without timezone |
| dry_run | boolean | If true, validate only without creating |
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
{
"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"
}
]
}
}
{
"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"
}
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"
}
}
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"
}
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"
}
POST /api/v1/agency/branding/logo
Upload agency logo. Request:multipart/form-data
| Field | Type | Description |
|---|---|---|
| file | file | Image file (JPG, PNG, WebP, SVG) |
| type | string | Logo type: horizontal, vertical, round, favicon |
{
"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"
}
{
"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"
}
{
"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
}
{
"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"
}
{
"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"
}
{
"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"
}
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:| Parameter | Type | Description |
|---|---|---|
| page | integer | Page number |
| per_page | integer | Items per page |
| search | string | Search in name |
| plan | string | Filter by plan name |
| status | string | Filter by subscription_status |
| sort | string | Sort field |
| order | string | Sort order |
{
"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"
}
GET /api/v1/admin/agencies/{agency_id}
Get agency details (admin view). Response (200 OK): Full agency object with all settings, usage stats, and notesPUT /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 statisticsPOST /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
| Step | ID | Description | Weight | Typical Duration |
|---|---|---|---|---|
| 1 | topic_analysis | Analyze topic and determine scope | 5% | 5-10s |
| 2 | keyword_research | Identify relevant keywords | 5% | 5-10s |
| 3 | web_research | Deep research from multiple sources | 30% | 30-120s |
| 4 | industry_analysis | Analyze industry-specific reports | 10% | 10-20s |
| 5 | outline_creation | Create content structure | 5% | 5-10s |
| 6 | content_writing | Generate all written content | 25% | 30-60s |
| 7 | statistics_integration | Add statistics and data | 5% | 5-10s |
| 8 | chart_generation | Create data visualizations | 5% | 10-20s |
| 9 | cover_image | Select/generate cover image | 3% | 5-15s |
| 10 | template_application | Apply design template | 4% | 5-10s |
| 11 | pdf_rendering | Render final PDF | 2% | 5-15s |
| 12 | quality_review | Final quality check | 1% | 2-5s |
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"
}
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)
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
)
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
| Platform | Post Type | Content | Link | Image |
|---|---|---|---|---|
| Article/Post | Summary text | PDF URL | Cover image | |
| Page Post | Summary text | PDF URL | Cover image | |
| Twitter/X | Tweet + Thread | Summary + key points | PDF URL | Cover image |
| Google Business | Post | Summary text | PDF URL | Cover image |
9.1.2 Distribution Flow
┌─────────────────────────────────────────────────────────────────────────────────┐
│ DISTRIBUTION FLOW │
└─────────────────────────────────────────────────────────────────────────────────┘
User Request Celery Worker Social Platform
│ │ │
▼ │ │
┌─────────────────┐ │ │
│ POST /distribute│ │ │
│ │ │ │
│ • Validate OAuth│ │ │
│ • Queue task │ │ │
└────────┬────────┘ │ │
│ │ │
│ Celery Task │ │
└─────────────────────────────►│ │
│ │
▼ │
┌─────────────────────┐ │
│ Load OAuth Tokens │ │
│ │ │
│ • Decrypt tokens │ │
│ • Refresh if needed │ │
└──────────┬──────────┘ │
│ │
┌─────────────┼─────────────┐ │
│ │ │ │
▼ ▼ ▼ │
┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ LinkedIn │ │ Facebook │ │ Twitter │ │
│ Worker │ │ Worker │ │ Worker │ │
└────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ │ │ │
│ │ │ │
└────────────┼────────────┘ │
│ │
▼ │
┌─────────────────────┐ │
│ Generate Social │ │
│ Content │ │
│ │ │
│ • Create summary │ │
│ • Format for platform │
│ • Add hashtags │ │
│ • Include PDF URL │ │
└──────────┬──────────┘ │
│ │
▼ │
┌─────────────────────┐ │
│ Upload Media │───────────────────►│
│ │ │
│ • Upload cover image│ Platform API │
│ • Get media ID │◄───────────────────│
└──────────┬──────────┘ │
│ │
▼ │
┌─────────────────────┐ │
│ Create Post │───────────────────►│
│ │ │
│ • Submit post │ Platform API │
│ • Get post ID/URL │◄───────────────────│
└──────────┬──────────┘ │
│ │
▼ │
┌─────────────────────┐ │
│ Update Document │ │
│ │ │
│ • Save post IDs │ │
│ • Save post URLs │ │
│ • Update status │ │
└─────────────────────┘ │
9.2 Platform-Specific Implementations
9.2.1 LinkedIn Distribution
# 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
| Type | Example | Plan | Configuration |
|---|---|---|---|
| Default Subdomain | acme.contentstrategist.com | All | Automatic from slug |
| Unbranded File Host | authapi.net/files/acme/... | All | System default |
| Custom Domain | content.acmeagency.com | Enterprise | DNS + SSL setup |
11.1.2 Domain Resolution Middleware
# 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
| Area | Implementation |
|---|---|
| Authentication | JWT with short expiry (15 min), refresh tokens in HTTP-only cookies |
| Password Storage | bcrypt with cost factor 12 |
| API Keys | SHA-256 hashed, prefixed for identification |
| Sensitive Data | Encrypted at rest (Fernet symmetric encryption) |
| SQL Injection | SQLAlchemy ORM with parameterized queries |
| XSS | Pydantic validation, proper escaping in templates |
| CSRF | SameSite cookies, custom headers for API |
| Rate Limiting | Redis-based sliding window |
| Input Validation | Pydantic schemas for all inputs |
| File Uploads | Type validation, size limits, isolated storage |
18.2 Encryption Service
# 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
| Category | Description | Target Coverage |
|---|---|---|
| Unit Tests | Individual functions/methods | 80% |
| Integration Tests | API endpoints, database | 70% |
| E2E Tests | Full generation flow | Critical paths |
21.2 Test Configuration
# 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
| Method | Endpoint | Description |
|---|---|---|
| POST | /api/v1/auth/login | Login |
| POST | /api/v1/auth/refresh | Refresh token |
| GET | /api/v1/auth/me | Current user |
| GET | /api/v1/clients | List clients |
| POST | /api/v1/clients | Create client |
| GET | /api/v1/clients/{id} | Get client |
| PUT | /api/v1/clients/{id} | Update client |
| POST | /api/v1/clients/{id}/documents/generate | Start generation |
| GET | /api/v1/documents/{id} | Get document |
| GET | /api/v1/documents/{id}/status | Get generation status |
| POST | /api/v1/documents/{id}/distribute | Distribute document |
| GET | /api/v1/clients/{id}/schedule | List scheduled content |
| POST | /api/v1/clients/{id}/schedule/import | Import CSV |
| GET | /api/v1/templates | List templates |
| GET | /api/v1/agency | Get agency settings |
| PUT | /api/v1/agency | Update agency |
Document Version History
| Version | Date | Author | Changes |
|---|---|---|---|
| 2.0 | Jan 2, 2026 | Claude | Complete comprehensive rewrite |
END OF DEVELOPER PRD
22. Frontend Architecture
22.1 Frontend Technology Stack
| Component | Technology | Version | Purpose |
|---|---|---|---|
| Framework | Next.js | 14+ | React framework with App Router |
| Language | TypeScript | 5.3+ | Type-safe JavaScript |
| Styling | Tailwind CSS | 3.4+ | Utility-first CSS |
| Components | shadcn/ui | latest | Accessible component primitives |
| State | Zustand | 4.5+ | Lightweight state management |
| Data Fetching | TanStack Query | 5+ | Server state management |
| Forms | React Hook Form | 7.49+ | Performant form handling |
| Validation | Zod | 3.22+ | Schema validation |
| HTTP Client | Axios | 1.6+ | API requests |
| WebSocket | socket.io-client | 4.7+ | Real-time updates |
| Charts | Recharts | 2.10+ | Data visualization |
| Tables | TanStack Table | 8.11+ | Headless table logic |
| Date Handling | date-fns | 3.0+ | Date utilities |
| Icons | Lucide React | 0.300+ | Icon library |
| PDF Preview | react-pdf | 7.7+ | PDF rendering |
| File Upload | react-dropzone | 14.2+ | Drag-and-drop uploads |
| Toast | Sonner | 1.3+ | Toast notifications |
| Animations | Framer Motion | 10.18+ | Animations |
22.2 Frontend Project Structure
frontend/
│
├── public/
│ ├── favicon.ico
│ ├── logo.svg
│ └── images/
│ └── placeholder-cover.jpg
│
├── src/
│ ├── app/ # Next.js App Router
│ │ ├── (auth)/ # Auth route group (no layout)
│ │ │ ├── login/
│ │ │ │ └── page.tsx
│ │ │ ├── register/
│ │ │ │ └── page.tsx
│ │ │ ├── forgot-password/
│ │ │ │ └── page.tsx
│ │ │ └── reset-password/
│ │ │ └── page.tsx
│ │ │
│ │ ├── (dashboard)/ # Dashboard route group (shared layout)
│ │ │ ├── layout.tsx # Dashboard layout with sidebar
│ │ │ ├── page.tsx # Dashboard home (redirect or overview)
│ │ │ │
│ │ │ ├── dashboard/
│ │ │ │ └── page.tsx # Main dashboard view
│ │ │ │
│ │ │ ├── clients/
│ │ │ │ ├── page.tsx # Client list
│ │ │ │ ├── new/
│ │ │ │ │ └── page.tsx # Create client
│ │ │ │ └── [clientId]/
│ │ │ │ ├── page.tsx # Client overview (default tab)
│ │ │ │ ├── documents/
│ │ │ │ │ └── page.tsx # Client documents tab
│ │ │ │ ├── schedule/
│ │ │ │ │ └── page.tsx # Client schedule tab
│ │ │ │ ├── settings/
│ │ │ │ │ └── page.tsx # Client settings tab
│ │ │ │ └── edit/
│ │ │ │ └── page.tsx # Edit client
│ │ │ │
│ │ │ ├── documents/
│ │ │ │ ├── page.tsx # All documents list
│ │ │ │ └── [documentId]/
│ │ │ │ └── page.tsx # Document detail view
│ │ │ │
│ │ │ ├── generate/
│ │ │ │ ├── page.tsx # Generation form (select client first)
│ │ │ │ ├── [clientId]/
│ │ │ │ │ └── page.tsx # Generation form for specific client
│ │ │ │ └── progress/
│ │ │ │ └── [jobId]/
│ │ │ │ └── page.tsx # Generation progress view
│ │ │ │
│ │ │ ├── schedule/
│ │ │ │ ├── page.tsx # Schedule calendar/list view
│ │ │ │ ├── new/
│ │ │ │ │ └── page.tsx # Create scheduled item
│ │ │ │ ├── import/
│ │ │ │ │ └── page.tsx # CSV import page
│ │ │ │ └── [scheduleId]/
│ │ │ │ └── edit/
│ │ │ │ └── page.tsx # Edit scheduled item
│ │ │ │
│ │ │ ├── templates/
│ │ │ │ └── page.tsx # Browse available templates
│ │ │ │
│ │ │ ├── team/
│ │ │ │ ├── page.tsx # Team members list
│ │ │ │ ├── invite/
│ │ │ │ │ └── page.tsx # Invite team member
│ │ │ │ └── [userId]/
│ │ │ │ └── edit/
│ │ │ │ └── page.tsx # Edit team member
│ │ │ │
│ │ │ └── settings/
│ │ │ ├── page.tsx # Settings overview/general
│ │ │ ├── general/
│ │ │ │ └── page.tsx # General settings
│ │ │ ├── branding/
│ │ │ │ └── page.tsx # Branding settings
│ │ │ ├── integrations/
│ │ │ │ └── page.tsx # Social integrations
│ │ │ ├── api-keys/
│ │ │ │ └── page.tsx # API key management
│ │ │ └── billing/
│ │ │ └── page.tsx # Billing/plan info
│ │ │
│ │ ├── (admin)/ # Super Admin route group
│ │ │ ├── layout.tsx # Admin layout
│ │ │ └── admin/
│ │ │ ├── page.tsx # Admin dashboard
│ │ │ ├── agencies/
│ │ │ │ ├── page.tsx # Agency list
│ │ │ │ ├── new/
│ │ │ │ │ └── page.tsx # Create agency
│ │ │ │ └── [agencyId]/
│ │ │ │ └── page.tsx # Agency detail/edit
│ │ │ ├── templates/
│ │ │ │ ├── page.tsx # Template management
│ │ │ │ ├── new/
│ │ │ │ │ └── page.tsx # Create template
│ │ │ │ └── [templateId]/
│ │ │ │ └── edit/
│ │ │ │ └── page.tsx # Edit template
│ │ │ ├── plans/
│ │ │ │ └── page.tsx # Plan management
│ │ │ └── system/
│ │ │ └── page.tsx # System health/stats
│ │ │
│ │ ├── api/ # API routes (if needed for BFF)
│ │ │ └── auth/
│ │ │ └── [...nextauth]/
│ │ │ └── route.ts
│ │ │
│ │ ├── layout.tsx # Root layout
│ │ ├── loading.tsx # Global loading state
│ │ ├── error.tsx # Global error boundary
│ │ ├── not-found.tsx # 404 page
│ │ └── globals.css # Global styles
│ │
│ ├── components/
│ │ ├── ui/ # shadcn/ui components
│ │ │ ├── button.tsx
│ │ │ ├── input.tsx
│ │ │ ├── select.tsx
│ │ │ ├── checkbox.tsx
│ │ │ ├── radio-group.tsx
│ │ │ ├── switch.tsx
│ │ │ ├── textarea.tsx
│ │ │ ├── label.tsx
│ │ │ ├── card.tsx
│ │ │ ├── dialog.tsx
│ │ │ ├── dropdown-menu.tsx
│ │ │ ├── popover.tsx
│ │ │ ├── tooltip.tsx
│ │ │ ├── tabs.tsx
│ │ │ ├── table.tsx
│ │ │ ├── badge.tsx
│ │ │ ├── avatar.tsx
│ │ │ ├── progress.tsx
│ │ │ ├── skeleton.tsx
│ │ │ ├── separator.tsx
│ │ │ ├── sheet.tsx
│ │ │ ├── alert.tsx
│ │ │ ├── alert-dialog.tsx
│ │ │ ├── calendar.tsx
│ │ │ ├── command.tsx
│ │ │ ├── form.tsx
│ │ │ └── scroll-area.tsx
│ │ │
│ │ ├── layout/ # Layout components
│ │ │ ├── sidebar.tsx # Main navigation sidebar
│ │ │ ├── sidebar-nav.tsx # Sidebar navigation items
│ │ │ ├── header.tsx # Top header bar
│ │ │ ├── breadcrumbs.tsx # Breadcrumb navigation
│ │ │ ├── page-header.tsx # Page title + actions
│ │ │ ├── mobile-nav.tsx # Mobile bottom navigation
│ │ │ └── user-menu.tsx # User dropdown menu
│ │ │
│ │ ├── dashboard/ # Dashboard-specific components
│ │ │ ├── stat-card.tsx # Statistics card
│ │ │ ├── recent-documents.tsx # Recent docs list
│ │ │ ├── quick-actions.tsx # Quick action buttons
│ │ │ ├── activity-chart.tsx # Activity line chart
│ │ │ └── upcoming-schedule.tsx # Upcoming items
│ │ │
│ │ ├── clients/ # Client-related components
│ │ │ ├── client-card.tsx # Client card for grid
│ │ │ ├── client-list.tsx # Client list/grid container
│ │ │ ├── client-form.tsx # Create/edit client form
│ │ │ ├── client-header.tsx # Client detail header
│ │ │ ├── client-tabs.tsx # Client detail tabs
│ │ │ ├── client-stats.tsx # Client statistics row
│ │ │ ├── client-filters.tsx # Search/filter controls
│ │ │ └── logo-upload.tsx # Client logo uploader
│ │ │
│ │ ├── documents/ # Document-related components
│ │ │ ├── document-card.tsx # Document card for grid
│ │ │ ├── document-table.tsx # Document data table
│ │ │ ├── document-preview.tsx # PDF preview component
│ │ │ ├── document-header.tsx # Document detail header
│ │ │ ├── document-details.tsx # Generation details panel
│ │ │ ├── document-actions.tsx # Action buttons
│ │ │ ├── document-filters.tsx # Search/filter controls
│ │ │ └── distribution-status.tsx # Distribution status panel
│ │ │
│ │ ├── generation/ # Content generation components
│ │ │ ├── generation-form.tsx # Main generation form
│ │ │ ├── topic-input.tsx # Topic input with suggestions
│ │ │ ├── template-select.tsx # Template selection dropdown
│ │ │ ├── tone-select.tsx # Tone selection
│ │ │ ├── keyword-input.tsx # Keyword tag input
│ │ │ ├── service-checkboxes.tsx # Service selection
│ │ │ ├── distribution-settings.tsx # Distribution config
│ │ │ ├── advanced-options.tsx # Collapsible advanced options
│ │ │ ├── generation-progress.tsx # Progress display
│ │ │ ├── progress-steps.tsx # Step indicator
│ │ │ ├── activity-log.tsx # Real-time activity log
│ │ │ └── generation-complete.tsx # Completion view
│ │ │
│ │ ├── schedule/ # Schedule-related components
│ │ │ ├── schedule-calendar.tsx # Calendar view
│ │ │ ├── schedule-list.tsx # List view
│ │ │ ├── schedule-item.tsx # Single scheduled item
│ │ │ ├── schedule-form.tsx # Create/edit form
│ │ │ ├── csv-import.tsx # CSV import wizard
│ │ │ ├── csv-preview.tsx # CSV validation preview
│ │ │ ├── day-detail.tsx # Selected day detail panel
│ │ │ └── recurrence-picker.tsx # Recurrence options
│ │ │
│ │ ├── distribution/ # Distribution components
│ │ │ ├── distribution-modal.tsx # Distribution dialog
│ │ │ ├── platform-select.tsx # Platform checkboxes
│ │ │ ├── post-preview.tsx # Social post preview
│ │ │ ├── distribution-results.tsx # Results display
│ │ │ └── connection-status.tsx # Platform connection status
│ │ │
│ │ ├── templates/ # Template-related components
│ │ │ ├── template-card.tsx # Template preview card
│ │ │ ├── template-grid.tsx # Template gallery
│ │ │ └── template-preview.tsx # Full template preview
│ │ │
│ │ ├── team/ # Team management components
│ │ │ ├── team-table.tsx # Team members table
│ │ │ ├── invite-form.tsx # Invite member form
│ │ │ ├── role-select.tsx # Role selection
│ │ │ └── member-actions.tsx # Member action buttons
│ │ │
│ │ ├── settings/ # Settings components
│ │ │ ├── settings-nav.tsx # Settings sidebar nav
│ │ │ ├── general-form.tsx # General settings form
│ │ │ ├── branding-form.tsx # Branding settings form
│ │ │ ├── color-picker.tsx # Brand color picker
│ │ │ ├── logo-upload.tsx # Logo upload component
│ │ │ ├── integration-card.tsx # Social integration card
│ │ │ ├── api-key-table.tsx # API keys table
│ │ │ ├── api-key-form.tsx # Create API key form
│ │ │ └── plan-display.tsx # Current plan display
│ │ │
│ │ ├── admin/ # Admin-specific components
│ │ │ ├── agency-table.tsx # Agency management table
│ │ │ ├── agency-form.tsx # Agency create/edit form
│ │ │ ├── template-form.tsx # Template create/edit form
│ │ │ ├── plan-form.tsx # Plan configuration form
│ │ │ ├── system-stats.tsx # System statistics
│ │ │ └── health-status.tsx # Health check display
│ │ │
│ │ └── shared/ # Shared/common components
│ │ ├── logo.tsx # App logo component
│ │ ├── loading-spinner.tsx # Loading spinner
│ │ ├── loading-skeleton.tsx # Skeleton loaders
│ │ ├── empty-state.tsx # Empty state display
│ │ ├── error-state.tsx # Error state display
│ │ ├── confirm-dialog.tsx # Confirmation dialog
│ │ ├── data-table.tsx # Generic data table
│ │ ├── pagination.tsx # Pagination controls
│ │ ├── search-input.tsx # Search input with debounce
│ │ ├── date-picker.tsx # Date picker wrapper
│ │ ├── date-range-picker.tsx # Date range picker
│ │ ├── file-upload.tsx # Generic file upload
│ │ ├── image-upload.tsx # Image upload with preview
│ │ ├── rich-text-editor.tsx # Rich text input (if needed)
│ │ ├── copy-button.tsx # Copy to clipboard
│ │ ├── status-badge.tsx # Status indicator badge
│ │ └── time-ago.tsx # Relative time display
│ │
│ ├── hooks/ # Custom React hooks
│ │ ├── use-auth.ts # Authentication hook
│ │ ├── use-agency.ts # Current agency context
│ │ ├── use-user.ts # Current user data
│ │ ├── use-permissions.ts # Permission checking
│ │ ├── use-websocket.ts # WebSocket connection
│ │ ├── use-generation-progress.ts # Generation progress subscription
│ │ ├── use-debounce.ts # Debounce hook
│ │ ├── use-local-storage.ts # Local storage hook
│ │ ├── use-media-query.ts # Responsive breakpoints
│ │ ├── use-clipboard.ts # Clipboard operations
│ │ └── use-toast.ts # Toast notifications
│ │
│ ├── lib/ # Library/utility code
│ │ ├── api/ # API client layer
│ │ │ ├── client.ts # Axios instance configuration
│ │ │ ├── auth.ts # Auth API calls
│ │ │ ├── agencies.ts # Agency API calls
│ │ │ ├── clients.ts # Client API calls
│ │ │ ├── documents.ts # Document API calls
│ │ │ ├── generation.ts # Generation API calls
│ │ │ ├── schedule.ts # Schedule API calls
│ │ │ ├── templates.ts # Template API calls
│ │ │ ├── team.ts # Team API calls
│ │ │ ├── distribution.ts # Distribution API calls
│ │ │ ├── settings.ts # Settings API calls
│ │ │ └── admin.ts # Admin API calls
│ │ │
│ │ ├── utils.ts # General utilities
│ │ ├── constants.ts # App constants
│ │ ├── validations.ts # Zod schemas
│ │ ├── format.ts # Formatting utilities
│ │ ├── dates.ts # Date utilities
│ │ └── colors.ts # Color utilities
│ │
│ ├── stores/ # Zustand stores
│ │ ├── auth-store.ts # Authentication state
│ │ ├── agency-store.ts # Agency context state
│ │ ├── ui-store.ts # UI state (sidebar, modals)
│ │ ├── generation-store.ts # Generation progress state
│ │ └── notification-store.ts # Notification state
│ │
│ ├── types/ # TypeScript type definitions
│ │ ├── api.ts # API response types
│ │ ├── auth.ts # Auth types
│ │ ├── agency.ts # Agency types
│ │ ├── client.ts # Client types
│ │ ├── document.ts # Document types
│ │ ├── template.ts # Template types
│ │ ├── schedule.ts # Schedule types
│ │ ├── user.ts # User types
│ │ ├── generation.ts # Generation types
│ │ └── index.ts # Export all types
│ │
│ ├── config/ # Configuration
│ │ ├── site.ts # Site metadata
│ │ ├── navigation.ts # Navigation structure
│ │ └── api.ts # API configuration
│ │
│ └── styles/ # Additional styles
│ └── pdf-preview.css # PDF preview specific styles
│
├── .env.example # Environment template
├── .env.local # Local environment (git ignored)
├── .eslintrc.json # ESLint configuration
├── .prettierrc # Prettier configuration
├── next.config.js # Next.js configuration
├── tailwind.config.ts # Tailwind configuration
├── tsconfig.json # TypeScript configuration
├── postcss.config.js # PostCSS configuration
├── components.json # shadcn/ui configuration
└── package.json # Dependencies
22.3 State Management Architecture
22.3.1 State Categories
┌─────────────────────────────────────────────────────────────────────────────┐
│ STATE MANAGEMENT ARCHITECTURE │
└─────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ SERVER STATE │
│ (TanStack Query / SWR) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ • API Data (clients, documents, templates, etc.) │
│ • Cached and automatically refreshed │
│ • Handles loading, error, and stale states │
│ • Mutations with optimistic updates │
│ │
│ Examples: │
│ - useQuery(['clients']) → Client list │
│ - useQuery(['documents', clientId]) → Client's documents │
│ - useMutation(createClient) → Create with cache invalidation │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ CLIENT STATE │
│ (Zustand) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Auth Store │ │ Agency Store │ │ UI Store │ │
│ ├─────────────────┤ ├─────────────────┤ ├─────────────────┤ │
│ │ • user │ │ • currentAgency │ │ • sidebarOpen │ │
│ │ • tokens │ │ • branding │ │ • activeModal │ │
│ │ • isAuthenticated│ │ • permissions │ │ • theme │ │
│ │ • login() │ │ • setAgency() │ │ • toggleSidebar()│ │
│ │ • logout() │ │ │ │ • openModal() │ │
│ └─────────────────┘ └─────────────────┘ └─────────────────┘ │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Generation Store│ │Notification Store│ │
│ ├─────────────────┤ ├─────────────────┤ │
│ │ • activeJobs │ │ • notifications │ │
│ │ • progress │ │ • unreadCount │ │
│ │ • updateProgress│ │ • add() │ │
│ │ • clearJob() │ │ • markRead() │ │
│ └─────────────────┘ └─────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ LOCAL STATE │
│ (React useState/useReducer) │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ • Form input values │
│ • Component-specific UI state (dropdowns open, tabs active) │
│ • Temporary state that doesn't need to be shared │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
22.3.2 Zustand Store Implementations
// 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"
}
}