15 KiB
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
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:
curl -H "Authorization: Bearer <token>" \
"http://api.cmlite.org/api/campaigns?page=1&limit=10&search=climate&status=ACTIVE"
Response (200 OK):
{
"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:
// 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:
{
"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:
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<string> {
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):
{
"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:
curl http://api.cmlite.org/api/public/campaigns?highlighted=true&limit=10
Response (200 OK):
{
"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:
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:
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:
// 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:
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:
if (data.title) {
const newSlug = generateSlug(data.title);
updateData.slug = await resolveSlugCollision(newSlug, id);
}
Validation Schemas
Create Campaign Schema
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
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
import axios from 'axios';
const fetchActiveCampaigns = async () => {
const { data } = await axios.get('/api/public/campaigns?highlighted=true');
return data.campaigns;
};
Admin: Update Campaign Status
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:
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
const [pagination, setPagination] = useState({ page: 1, limit: 20, total: 0 });
const [filters, setFilters] = useState({ search: '', status: null });
Related Documentation
- Representatives Module - Postal code → rep lookup
- Responses Module - Response wall + moderation
- Campaign Emails Module - Email tracking
- Email Queue Module - BullMQ email sending
- Frontend: CampaignsPage - Campaign management UI
- Frontend: Public Campaign Page - Public campaign view
- API Reference: Campaigns - Complete endpoint reference
- User Guide: Campaign Manager - Creating campaigns guide