# Campaigns Module ## Overview The Campaigns module manages advocacy email campaigns targeting elected representatives. It provides comprehensive CRUD operations with rich feature flags, automatic slug generation, and role-based visibility controls. Campaigns integrate with the representative lookup system, email sending queue, and public response wall. **Key Features:** - Full CRUD with pagination, search, and status filtering - Auto-generated slugs from campaign titles (collision-safe) - Feature flags (SMTP email, mailto links, response wall, highlighting, etc.) - Government level targeting (Federal, Provincial, Municipal, School Board) - Email count and call count tracking - Public vs admin visibility (non-admins see only their own campaigns) - Integration with email queue, representatives, and responses modules - Cover photo support (URL-based) ## File Paths | File | Purpose | |------|---------| | `api/src/modules/influence/campaigns/campaigns.routes.ts` | Admin router with 5 CRUD endpoints | | `api/src/modules/influence/campaigns/campaigns-public.routes.ts` | Public router (2 endpoints, no auth) | | `api/src/modules/influence/campaigns/campaigns.service.ts` | Campaign business logic | | `api/src/modules/influence/campaigns/campaigns.schemas.ts` | Zod validation schemas | ## Database Model ```prisma model Campaign { id String @id @default(cuid()) slug String @unique title String description String? emailSubject String emailBody String callToAction String? coverPhoto String? status CampaignStatus @default(DRAFT) targetGovernmentLevels GovernmentLevel[] // Feature flags allowSmtpEmail Boolean @default(true) allowMailtoLink Boolean @default(true) collectUserInfo Boolean @default(true) showEmailCount Boolean @default(true) showCallCount Boolean @default(true) allowEmailEditing Boolean @default(false) allowCustomRecipients Boolean @default(false) showResponseWall Boolean @default(false) highlightCampaign Boolean @default(false) // Creator tracking createdByUserId String createdByUserEmail String createdByUserName String? // Relations emails CampaignEmail[] responses Response[] customRecipients CustomRecipient[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([status]) @@index([createdByUserId]) } enum CampaignStatus { DRAFT // Not visible to public ACTIVE // Live on public site PAUSED // Temporarily hidden ARCHIVED // Completed/historical } enum GovernmentLevel { FEDERAL // MPs, Prime Minister PROVINCIAL // MPPs, MLAs, Premier MUNICIPAL // Councillors, Mayor SCHOOL_BOARD // School board trustees } ``` ## API Endpoints ### Admin Endpoints (Authentication Required) | Method | Path | Auth | Description | |--------|------|------|-------------| | GET | `/api/campaigns` | Admin roles | List campaigns with pagination/filters | | GET | `/api/campaigns/:id` | Admin roles | Get single campaign by ID | | POST | `/api/campaigns` | Admin roles | Create new campaign | | PUT | `/api/campaigns/:id` | Admin roles | Update campaign | | DELETE | `/api/campaigns/:id` | Admin roles | Delete campaign | **Admin Roles:** `SUPER_ADMIN`, `INFLUENCE_ADMIN`, `MAP_ADMIN` ### Public Endpoints (No Authentication) | Method | Path | Auth | Description | |--------|------|------|-------------| | GET | `/api/public/campaigns` | None | List active/highlighted campaigns | | GET | `/api/public/campaigns/:slug` | None | Get campaign by slug | ## Admin Endpoint Details ### GET /api/campaigns List campaigns with pagination, search, and filtering. Non-admin users see only their own campaigns. **Query Parameters:** | Parameter | Type | Required | Default | Description | |-----------|------|----------|---------|-------------| | page | number | No | 1 | Page number | | limit | number | No | 20 | Results per page (max 100) | | search | string | No | - | Search title or description | | status | CampaignStatus | No | - | Filter by status | **Example Request:** ```bash curl -H "Authorization: Bearer " \ "http://api.cmlite.org/api/campaigns?page=1&limit=10&search=climate&status=ACTIVE" ``` **Response (200 OK):** ```json { "campaigns": [ { "id": "clx1234567890", "slug": "climate-action-now", "title": "Climate Action Now", "description": "Demand bold climate policies from your representatives", "emailSubject": "Pass the Climate Emergency Bill", "emailBody": "Dear [Representative Name],\n\n...", "callToAction": "Send your email now!", "coverPhoto": "https://example.com/climate.jpg", "status": "ACTIVE", "targetGovernmentLevels": ["FEDERAL", "PROVINCIAL"], "allowSmtpEmail": true, "allowMailtoLink": true, "collectUserInfo": true, "showEmailCount": true, "showCallCount": false, "allowEmailEditing": false, "allowCustomRecipients": false, "showResponseWall": true, "highlightCampaign": true, "createdByUserId": "clx0987654321", "createdByUserEmail": "admin@example.com", "createdByUserName": "Admin User", "createdAt": "2026-02-01T12:00:00.000Z", "updatedAt": "2026-02-11T12:00:00.000Z", "_count": { "emails": 342, "responses": 89 } } ], "pagination": { "page": 1, "limit": 10, "total": 15, "totalPages": 2 } } ``` **Visibility Rules:** ```typescript // Non-admin users only see their own campaigns const adminRoles: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN]; if (user && !adminRoles.includes(user.role)) { where.createdByUserId = user.id; } ``` --- ### POST /api/campaigns Create new campaign with auto-generated slug. **Request Body:** ```json { "title": "Climate Action Now", "description": "Demand bold climate policies", "emailSubject": "Pass the Climate Emergency Bill", "emailBody": "Dear [Representative Name],\n\nI urge you to...", "callToAction": "Send your email now!", "coverPhoto": "https://example.com/climate.jpg", "status": "DRAFT", "targetGovernmentLevels": ["FEDERAL", "PROVINCIAL"], "allowSmtpEmail": true, "allowMailtoLink": true, "showResponseWall": true, "highlightCampaign": true } ``` **Response (201 Created):** Returns created campaign object (same format as GET). **Slug Generation:** ```typescript function generateSlug(title: string): string { return title .toLowerCase() .replace(/[^a-z0-9]+/g, '-') // Replace non-alphanumeric with - .replace(/^-+|-+$/g, '') // Remove leading/trailing - .slice(0, 80); // Max 80 chars } // Collision detection async function resolveSlugCollision(slug: string, excludeId?: string): Promise { let candidate = slug; let suffix = 2; while (true) { const existing = await prisma.campaign.findUnique({ where: { slug: candidate } }); if (!existing || (excludeId && existing.id === excludeId)) { return candidate; } candidate = `${slug}-${suffix}`; // climate-action-now-2 suffix++; } } ``` **Example Slug Transformations:** - `"Climate Action NOW!"` → `climate-action-now` - `"Email Your MP: Support Bill C-12"` → `email-your-mp-support-bill-c-12` - `"Climate Action Now"` (2nd with same title) → `climate-action-now-2` --- ### PUT /api/campaigns/:id Update campaign. Partial updates supported. Slug regenerated if title changes. **Request Body (Partial):** ```json { "status": "ACTIVE", "highlightCampaign": true, "showResponseWall": true } ``` **Response (200 OK):** Returns updated campaign object. --- ### DELETE /api/campaigns/:id Delete campaign and cascade to related records. **Response (204 No Content):** No response body. **Cascading Deletes:** - Campaign emails (all email send records) - Responses (all user responses) - Custom recipients --- ## Public Endpoint Details ### GET /api/public/campaigns List active and highlighted campaigns (no auth required). **Query Parameters:** | Parameter | Type | Description | |-----------|------|-------------| | highlighted | boolean | Filter to highlighted campaigns only | | limit | number | Results per page (max 50, default 20) | **Example Request:** ```bash curl http://api.cmlite.org/api/public/campaigns?highlighted=true&limit=10 ``` **Response (200 OK):** ```json { "campaigns": [ { "id": "clx1234567890", "slug": "climate-action-now", "title": "Climate Action Now", "description": "Demand bold climate policies", "callToAction": "Send your email now!", "coverPhoto": "https://example.com/climate.jpg", "status": "ACTIVE", "highlightCampaign": true, "showEmailCount": true, "showCallCount": false, "_count": { "emails": 342, "responses": 89 } } ] } ``` **Filtering:** ```typescript const where: Prisma.CampaignWhereInput = { status: CampaignStatus.ACTIVE, // Only active campaigns }; if (highlighted === 'true') { where.highlightCampaign = true; } ``` --- ### GET /api/public/campaigns/:slug Get campaign by slug (no auth required). **Path Parameters:** - `slug` (string): Campaign slug **Example Request:** ```bash curl http://api.cmlite.org/api/public/campaigns/climate-action-now ``` **Response (200 OK):** Returns full campaign object (same as admin GET). **Error Responses:** - `404 Not Found`: Campaign not found or not ACTIVE --- ## Service Functions ### campaignsService.findAll(filters, user) List campaigns with role-based visibility. **Visibility Logic:** ```typescript // Admin users see all campaigns // Non-admin users see only their own campaigns const adminRoles: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN]; if (user && !adminRoles.includes(user.role)) { where.createdByUserId = user.id; } ``` --- ### campaignsService.create(data, user) Create campaign with auto-generated slug and creator tracking. **Creator Tracking:** ```typescript const campaign = await prisma.campaign.create({ data: { ...data, slug: await resolveSlugCollision(generateSlug(data.title)), createdByUserId: user.id, createdByUserEmail: user.email, createdByUserName: user.name || null, }, select: campaignSelect, }); ``` --- ### campaignsService.update(id, data) Update campaign. Regenerates slug if title changes. **Slug Regeneration:** ```typescript if (data.title) { const newSlug = generateSlug(data.title); updateData.slug = await resolveSlugCollision(newSlug, id); } ``` --- ## Validation Schemas ### Create Campaign Schema ```typescript export const createCampaignSchema = z.object({ title: z.string().min(1, 'Title is required'), description: z.string().optional(), emailSubject: z.string().min(1, 'Email subject is required'), emailBody: z.string().min(1, 'Email body is required'), callToAction: z.string().optional(), status: z.nativeEnum(CampaignStatus).optional().default(CampaignStatus.DRAFT), targetGovernmentLevels: z.array(z.nativeEnum(GovernmentLevel)).optional().default([]), allowSmtpEmail: z.boolean().optional().default(true), allowMailtoLink: z.boolean().optional().default(true), collectUserInfo: z.boolean().optional().default(true), showEmailCount: z.boolean().optional().default(true), showCallCount: z.boolean().optional().default(true), allowEmailEditing: z.boolean().optional().default(false), allowCustomRecipients: z.boolean().optional().default(false), showResponseWall: z.boolean().optional().default(false), highlightCampaign: z.boolean().optional().default(false), coverPhoto: z.string().optional(), }); ``` ## Feature Flags | Flag | Default | Description | |------|---------|-------------| | `allowSmtpEmail` | `true` | Enable direct SMTP email sending via queue | | `allowMailtoLink` | `true` | Show mailto: link option (opens default email client) | | `collectUserInfo` | `true` | Collect sender name, email, postal code | | `showEmailCount` | `true` | Display email send count on public page | | `showCallCount` | `true` | Display call count (future feature) | | `allowEmailEditing` | `false` | Let users edit email template before sending | | `allowCustomRecipients` | `false` | Allow manual recipient selection (overrides postal code lookup) | | `showResponseWall` | `false` | Enable public response submission + display | | `highlightCampaign` | `false` | Featured campaign (shown on homepage) | ## Code Examples ### Admin: Create Campaign ```typescript import { api } from '@/lib/api'; const createCampaign = async () => { const { data } = await api.post('/api/campaigns', { title: 'Climate Action Now', emailSubject: 'Pass the Climate Emergency Bill', emailBody: 'Dear [Representative Name],\n\nI urge you to support immediate climate action...', targetGovernmentLevels: ['FEDERAL', 'PROVINCIAL'], status: 'DRAFT', showResponseWall: true, highlightCampaign: true, }); console.log(`Campaign created: ${data.slug}`); return data; }; ``` ### Public: List Active Campaigns ```typescript import axios from 'axios'; const fetchActiveCampaigns = async () => { const { data } = await axios.get('/api/public/campaigns?highlighted=true'); return data.campaigns; }; ``` ### Admin: Update Campaign Status ```typescript import { api } from '@/lib/api'; const publishCampaign = async (id: string) => { const { data } = await api.put(`/api/campaigns/${id}`, { status: 'ACTIVE', }); message.success('Campaign published!'); return data; }; ``` ## Frontend Integration The CampaignsPage component (`admin/src/pages/CampaignsPage.tsx`) provides: - Paginated table with search and status filter - Feature flag badges (SMTP, Response Wall, Highlighted, etc.) - Create campaign modal with rich text editor (TinyMCE/Quill) - Edit campaign modal (pre-populated form) - Delete confirmation modal - Email count drawer (shows campaign email stats) - Publish/archive actions (status toggle) **State Management:** ```typescript const [campaigns, setCampaigns] = useState([]); const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0 }); const [filters, setFilters] = useState({ search: '', status: null }); ``` ## Related Documentation - [Representatives Module](/v2/backend/modules/representatives.md) - Postal code → rep lookup - [Responses Module](/v2/backend/modules/responses.md) - Response wall + moderation - [Campaign Emails Module](/v2/backend/modules/campaign-emails.md) - Email tracking - [Email Queue Module](/v2/backend/modules/email-queue.md) - BullMQ email sending - [Frontend: CampaignsPage](/v2/frontend/pages/admin/campaigns-page.md) - Campaign management UI - [Frontend: Public Campaign Page](/v2/frontend/pages/public/campaign-page.md) - Public campaign view - [API Reference: Campaigns](/v2/api-reference/campaigns.md) - Complete endpoint reference - [User Guide: Campaign Manager](/v2/user-guides/campaign-manager-guide.md) - Creating campaigns guide