22 KiB
Campaign Management System
Overview
The campaign management system is the core of Changemaker Lite's advocacy email platform. It enables organizations to create, configure, and manage advocacy campaigns that allow supporters to contact elected representatives via email. The system supports multiple campaign types, customizable features via feature flags, and a complete lifecycle from draft to archived status.
Key Capabilities:
- Multi-status lifecycle: Draft → Active → Paused → Archived workflow
- 12 feature flags: Granular control over campaign behavior
- Government level filtering: Target specific levels (federal, provincial, municipal)
- Cover photo uploads: Visual campaign branding
- Slug-based routing: SEO-friendly public URLs
- Response wall integration: Public display of campaign responses
- Email tracking: Monitor sent emails and campaign effectiveness
Use Cases:
- Advocacy campaigns targeting elected officials
- Public awareness campaigns with response sharing
- Email-your-MP initiatives
- Multi-level government outreach
- Time-limited advocacy actions
Architecture
graph TD
A[Admin User] -->|Creates Campaign| B[CampaignsPage]
B -->|POST /api/campaigns| C[Campaign Service]
C -->|Save| D[(Campaign Model)]
E[Public User] -->|Browses| F[CampaignsListPage]
F -->|GET /api/public/campaigns| C
E -->|Views Campaign| G[CampaignPage]
G -->|GET /api/public/campaigns/:slug| C
G -->|Lookup Reps| H[Representatives Service]
G -->|Send Email| I[Email Queue Service]
I -->|Add Job| J[(BullMQ Redis)]
K[Email Worker] -->|Process Jobs| J
K -->|Send SMTP| L[Email Recipients]
K -->|Track| M[(CampaignEmail Model)]
D -->|1:N| M
D -->|1:N| N[(Response Model)]
style D fill:#e1f5ff
style M fill:#e1f5ff
style N fill:#e1f5ff
style J fill:#fff4e1
Flow Description:
- Admin creates campaign → Campaign service validates and saves to database
- Public user browses → Campaign service returns active campaigns
- User views campaign → Representatives service looks up postal code
- User sends email → Email queue service adds job to BullMQ
- Worker processes job → Email sent via SMTP, tracked in CampaignEmail model
- User submits response → Response service creates response for moderation
Database Models
Campaign Model
See Campaign Model Documentation for full schema.
Key Fields:
status: DRAFT | ACTIVE | PAUSED | ARCHIVEDtargetGovernmentLevels: Array of government levels (federal, provincial, municipal)emailSubjectTemplate: Subject line with {{VAR}} placeholdersemailBodyTemplate: Email body with {{VAR}} placeholderscoverPhotoUrl: Campaign hero image URLslug: URL-friendly identifier
Feature Flags (12 total):
| Flag | Type | Default | Description |
|---|---|---|---|
allowSmtpEmail |
boolean | true | Enable email sending |
allowCallTracking |
boolean | false | Enable phone call logging |
showResponseWall |
boolean | true | Display response wall |
requireEmailVerification |
boolean | true | Verify response emails |
allowAnonymousResponses |
boolean | false | Allow responses without login |
highlightCampaign |
boolean | false | Feature on homepage |
showProgressBar |
boolean | true | Display response count progress |
allowSharing |
boolean | true | Enable social sharing buttons |
requirePostalCode |
boolean | true | Require postal code for lookup |
allowCustomMessage |
boolean | true | Users can edit email text |
trackEmailOpens |
boolean | false | Track email opens (future) |
notifyOnResponse |
boolean | true | Email admin on new responses |
Related Models:
- CampaignEmail — Tracks sent emails
- Response — Public responses to campaign
- Representative — Email recipients
API Endpoints
Admin Endpoints
See Campaigns Module API Reference for full details.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /api/campaigns |
SUPER_ADMIN, INFLUENCE_ADMIN | List all campaigns (paginated) |
| GET | /api/campaigns/:id |
SUPER_ADMIN, INFLUENCE_ADMIN | Get campaign details |
| POST | /api/campaigns |
SUPER_ADMIN, INFLUENCE_ADMIN | Create new campaign |
| PUT | /api/campaigns/:id |
SUPER_ADMIN, INFLUENCE_ADMIN | Update campaign |
| PATCH | /api/campaigns/:id/status |
SUPER_ADMIN, INFLUENCE_ADMIN | Update campaign status |
| DELETE | /api/campaigns/:id |
SUPER_ADMIN | Delete campaign |
Public Endpoints
See Campaigns Public API Reference.
| Method | Endpoint | Auth | Description |
|---|---|---|---|
| GET | /api/public/campaigns |
None | List active campaigns |
| GET | /api/public/campaigns/:slug |
None | Get campaign by slug |
Configuration
Environment Variables
| Variable | Type | Default | Description |
|---|---|---|---|
EMAIL_TEST_MODE |
boolean | false | Send emails to MailHog instead of SMTP |
SMTP_HOST |
string | - | SMTP server hostname |
SMTP_PORT |
number | 587 | SMTP server port |
SMTP_USER |
string | - | SMTP username |
SMTP_PASS |
string | - | SMTP password |
SMTP_FROM_EMAIL |
string | - | Default sender email |
SMTP_FROM_NAME |
string | - | Default sender name |
Site Settings
SMTP settings can be configured via Site Settings (overrides env vars):
{
smtpHost: string | null,
smtpPort: number | null,
smtpUser: string | null,
smtpPass: string | null,
smtpFromEmail: string | null,
smtpFromName: string | null
}
Upload Configuration
Cover photos uploaded to /uploads/campaigns/{campaignId}/{filename}.
Limits:
- Max file size: 10MB
- Allowed formats: jpg, jpeg, png, gif, webp
Admin Workflow
1. Create Campaign
[Screenshot: CampaignsPage with "Create Campaign" button]
Steps:
- Navigate to Influence > Campaigns
- Click Create Campaign button
- Fill in campaign details:
- Title (required)
- Description (required)
- Target government levels (select all that apply)
- Email subject template (use {{VAR}} for dynamic content)
- Email body template (HTML supported)
- Upload cover photo (optional)
- Click Save (saves as DRAFT)
Code Example (CampaignsPage.tsx):
const handleCreate = async (values: any) => {
try {
const formData = new FormData();
formData.append('title', values.title);
formData.append('description', values.description);
formData.append('targetGovernmentLevels', JSON.stringify(values.targetGovernmentLevels));
formData.append('emailSubjectTemplate', values.emailSubjectTemplate);
formData.append('emailBodyTemplate', values.emailBodyTemplate);
if (values.coverPhoto?.[0]?.originFileObj) {
formData.append('coverPhoto', values.coverPhoto[0].originFileObj);
}
await api.post('/campaigns', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
message.success('Campaign created successfully');
fetchCampaigns();
} catch (error) {
message.error('Failed to create campaign');
}
};
2. Configure Feature Flags
[Screenshot: Campaign edit modal with feature flags section]
Steps:
- Click Edit on campaign row
- Scroll to Feature Flags section
- Toggle flags as needed:
- allowSmtpEmail: Enable email sending (required for email campaigns)
- showResponseWall: Display public response wall
- requireEmailVerification: Require email verification for responses
- highlightCampaign: Feature on homepage
- allowCustomMessage: Let users edit email text before sending
- Click Save
Best Practices:
- Enable
requireEmailVerificationfor public response walls - Disable
allowCustomMessageif you want consistent messaging - Use
highlightCampaignsparingly (max 2-3 campaigns) - Enable
showProgressBarto encourage participation
3. Test Campaign
[Screenshot: Campaign preview with test email form]
Steps:
- Set campaign status to ACTIVE
- Navigate to public campaign page:
/campaigns/{slug} - Enter test postal code
- Review representative lookup results
- Send test email to your own email address
- Verify email content and formatting
Troubleshooting:
- If no representatives found → Check Represent API cache
- If email not received → Check Email Queue page for job status
- If email formatting broken → Review HTML template syntax
4. Publish Campaign
[Screenshot: Campaign status dropdown]
Steps:
- Return to Campaigns page
- Click Status dropdown on campaign row
- Select ACTIVE
- Campaign now visible on public campaigns page
Status Lifecycle:
stateDiagram-v2
[*] --> DRAFT: Create
DRAFT --> ACTIVE: Publish
ACTIVE --> PAUSED: Pause
PAUSED --> ACTIVE: Resume
ACTIVE --> ARCHIVED: Archive
PAUSED --> ARCHIVED: Archive
ARCHIVED --> [*]
5. Monitor Campaign
[Screenshot: Campaign emails drawer with stats]
Steps:
- Click View Emails on campaign row
- Review email stats:
- Total sent
- Success rate
- Failed emails
- View individual email details (recipient, status, sent date)
- Retry failed emails if needed
Metrics to Track:
- Emails sent per day
- Response wall submissions
- Verification rate (if enabled)
- Geographic distribution (via postal codes)
Public Workflow
1. Browse Campaigns
[Screenshot: Public campaigns list page with featured campaigns]
User Journey:
- User visits
/campaigns - Sees featured campaigns (if
highlightCampaignenabled) - Browses active campaigns grid
- Clicks campaign card to view details
Code Example (CampaignsListPage.tsx):
const CampaignsListPage: React.FC = () => {
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
const [featured, setFeatured] = useState<Campaign[]>([]);
useEffect(() => {
const fetchCampaigns = async () => {
const { data } = await axios.get('/api/public/campaigns');
const featuredCampaigns = data.filter((c: Campaign) =>
c.highlightCampaign && c.status === 'ACTIVE'
);
const regularCampaigns = data.filter((c: Campaign) =>
!c.highlightCampaign && c.status === 'ACTIVE'
);
setFeatured(featuredCampaigns);
setCampaigns(regularCampaigns);
};
fetchCampaigns();
}, []);
return (
<PublicLayout>
{featured.length > 0 && (
<FeaturedCampaigns campaigns={featured} />
)}
<CampaignGrid campaigns={campaigns} />
</PublicLayout>
);
};
2. View Campaign Details
[Screenshot: Campaign detail page with postal code lookup form]
User Journey:
- User clicks campaign card
- Navigated to
/campaigns/{slug} - Reads campaign description
- Enters postal code in lookup form
- System fetches representatives from Represent API
- User selects representatives to email
3. Send Email
[Screenshot: Email form with representative selection]
User Journey:
- User reviews list of representatives
- Selects representatives to email (checkboxes)
- Reviews email subject and body
- Edits message if
allowCustomMessageenabled - Adds personal details (name, email)
- Clicks Send Email
- Email jobs added to BullMQ queue
- User sees confirmation message
Code Example (CampaignPage.tsx):
const handleSendEmails = async (values: any) => {
try {
const payload = {
campaignId: campaign.id,
senderName: values.senderName,
senderEmail: values.senderEmail,
postalCode: values.postalCode,
representativeIds: values.representativeIds,
customMessage: campaign.allowCustomMessage ? values.customMessage : null
};
await axios.post('/api/public/campaigns/send-email', payload);
message.success('Your emails have been sent!');
if (campaign.showResponseWall) {
message.info('Share your response on the Response Wall!');
}
} catch (error) {
message.error('Failed to send emails');
}
};
4. Submit Response (Optional)
[Screenshot: Response submission form]
User Journey:
- After sending email, user clicks Share Your Response
- Navigated to
/responses/{campaignId}/submit - Fills in response form:
- Type (EMAIL, LETTER, PHONE_CALL, etc.)
- Message
- Screenshot (optional)
- Submits response
- If
requireEmailVerificationenabled → verification email sent - User clicks verification link in email
- Response appears on public response wall (after admin approval if moderation enabled)
Volunteer Workflow
Not applicable — campaigns are admin-managed and public-facing.
Code Examples
Backend: Create Campaign
// api/src/modules/influence/campaigns/campaigns.service.ts
async createCampaign(
data: Prisma.CampaignUncheckedCreateInput,
createdByUserId: string
): Promise<Campaign> {
// Generate slug from title
const baseSlug = data.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
let slug = baseSlug;
let counter = 1;
// Ensure unique slug
while (await this.prisma.campaign.findUnique({ where: { slug } })) {
slug = `${baseSlug}-${counter}`;
counter++;
}
return this.prisma.campaign.create({
data: {
...data,
slug,
createdByUserId,
status: 'DRAFT',
// Default feature flags
allowSmtpEmail: data.allowSmtpEmail ?? true,
showResponseWall: data.showResponseWall ?? true,
requireEmailVerification: data.requireEmailVerification ?? true,
allowCustomMessage: data.allowCustomMessage ?? true,
showProgressBar: data.showProgressBar ?? true,
allowSharing: data.allowSharing ?? true,
requirePostalCode: data.requirePostalCode ?? true,
notifyOnResponse: data.notifyOnResponse ?? true
}
});
}
Frontend: Campaign Card Component
// admin/src/pages/public/CampaignsListPage.tsx
const CampaignCard: React.FC<{ campaign: Campaign }> = ({ campaign }) => {
const navigate = useNavigate();
return (
<Card
hoverable
cover={
campaign.coverPhotoUrl && (
<img
alt={campaign.title}
src={campaign.coverPhotoUrl}
style={{ height: 200, objectFit: 'cover' }}
/>
)
}
onClick={() => navigate(`/campaigns/${campaign.slug}`)}
>
<Card.Meta
title={campaign.title}
description={
<Space direction="vertical" size="small">
<Typography.Paragraph ellipsis={{ rows: 3 }}>
{campaign.description}
</Typography.Paragraph>
{campaign.showProgressBar && (
<Progress
percent={Math.min(
(campaign._count?.responses || 0) / (campaign.responseGoal || 100) * 100,
100
)}
status="active"
/>
)}
<Space>
{campaign.targetGovernmentLevels.map(level => (
<Tag key={level} color="blue">{level}</Tag>
))}
</Space>
</Space>
}
/>
</Card>
);
};
Troubleshooting
Campaign Not Visible on Public Page
Symptoms:
- Campaign exists in admin but doesn't appear on
/campaigns
Solutions:
- Check campaign status → must be
ACTIVE - Verify no draft campaigns leaked → filter by status in query
- Check Nginx caching → clear cache or disable for
/api/public/campaigns
Debugging:
# Check campaign status
docker compose exec v2-postgres psql -U changemaker -d changemaker_lite -c \
"SELECT id, title, status, slug FROM campaigns WHERE slug = 'your-slug';"
# Check public endpoint response
curl http://localhost:4000/api/public/campaigns | jq
Email Template Variables Not Replaced
Symptoms:
- Email sent with
{{senderName}}instead of actual name
Solutions:
- Verify variable syntax → must use double curly braces
{{VAR}} - Check email service interpolation → ensure
processTemplate()called - Verify variable names match →
senderName,senderEmail,postalCode,recipientName,recipientEmail
Code Fix (email.service.ts):
private processTemplate(template: string, variables: Record<string, string>): string {
let processed = template;
Object.entries(variables).forEach(([key, value]) => {
const regex = new RegExp(`{{${key}}}`, 'g');
processed = processed.replace(regex, value || '');
});
return processed;
}
Cover Photo Upload Fails
Symptoms:
- Upload spinner never completes
- Error: "File too large"
Solutions:
- Check file size → max 10MB
- Verify file format → must be jpg/jpeg/png/gif/webp
- Check upload directory permissions →
/uploads/campaignsmust be writable - Increase Nginx upload limit →
client_max_body_size 20M;
Docker Volume Fix:
# docker-compose.yml
services:
api:
volumes:
- ./uploads:/app/uploads:rw # Ensure :rw (read-write)
Representatives Not Loading
Symptoms:
- Postal code lookup returns empty array
Solutions:
- Check Represent API status → visit https://represent.opennorth.ca/health
- Verify postal code format → must be valid Canadian postal code (K1A 0A1)
- Check representative cache → may need refresh
- Review API rate limits → Represent API has rate limits
Manual Cache Refresh:
# Via admin UI
# Navigate to Influence > Representatives
# Enter postal code in search box
# Click "Lookup"
# Via API
curl -X POST http://localhost:4000/api/representatives/lookup \
-H "Content-Type: application/json" \
-d '{"postalCode": "K1A0A1"}'
Performance Considerations
Campaign Listing Optimization
Query Optimization:
// Include response count for progress bar
const campaigns = await prisma.campaign.findMany({
where: { status: 'ACTIVE' },
include: {
_count: {
select: { responses: true }
}
},
orderBy: [
{ highlightCampaign: 'desc' }, // Featured first
{ createdAt: 'desc' }
]
});
Caching Strategy:
- Cache active campaigns list for 5 minutes (Redis)
- Invalidate cache on campaign status change
- Use ETags for HTTP caching
Email Queue Scaling
BullMQ Configuration:
// api/src/services/email-queue.service.ts
const queue = new Queue('campaign-emails', {
connection: redisConnection,
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 5000 // 5s, 25s, 125s
},
removeOnComplete: {
age: 86400, // Keep completed jobs for 24h
count: 1000
},
removeOnFail: {
age: 604800 // Keep failed jobs for 7 days
}
}
});
// Worker concurrency
const worker = new Worker('campaign-emails', processCampaignEmail, {
connection: redisConnection,
concurrency: 5 // Process 5 emails simultaneously
});
Monitoring:
- Track queue size with Prometheus
cm_email_queue_sizemetric - Alert if queue size > 1000
- Monitor worker processing rate
Cover Photo Optimization
Image Processing:
// api/src/modules/influence/campaigns/campaigns.service.ts
import sharp from 'sharp';
async uploadCoverPhoto(file: Express.Multer.File, campaignId: string): Promise<string> {
const filename = `${Date.now()}-${file.originalname}`;
const uploadPath = `/uploads/campaigns/${campaignId}`;
// Create directory
await fs.mkdir(uploadPath, { recursive: true });
// Optimize image
await sharp(file.buffer)
.resize(1200, 630, { // Open Graph ratio
fit: 'cover',
position: 'center'
})
.jpeg({ quality: 85 })
.toFile(`${uploadPath}/${filename}`);
return `${uploadPath}/${filename}`;
}
CDN Integration:
- Serve cover photos via CDN (Cloudflare, CloudFront)
- Use responsive images with
srcset - Lazy load images below fold
Related Documentation
Backend Modules
- Campaigns Module — Full API reference
- Representatives Module — Represent API integration
- Responses Module — Response wall system
- Email Queue Module — BullMQ email processing
Frontend Pages
- CampaignsPage — Admin campaign management
- CampaignPage — Public campaign view
- CampaignsListPage — Public campaign listing
- ResponsesPage — Response moderation
Database Models
- Campaign — Campaign schema
- CampaignEmail — Email tracking schema
- Response — Response schema
- Representative — Representative schema
Configuration
- Environment Variables — SMTP configuration
- Site Settings — Global settings API
Guides
- Email Sending Guide — Email queue and BullMQ
- Response Wall Guide — Response moderation workflow
- Representative Lookup Guide — Represent API integration