754 lines
22 KiB
Markdown

# 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
```mermaid
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:**
1. **Admin creates campaign** → Campaign service validates and saves to database
2. **Public user browses** → Campaign service returns active campaigns
3. **User views campaign** → Representatives service looks up postal code
4. **User sends email** → Email queue service adds job to BullMQ
5. **Worker processes job** → Email sent via SMTP, tracked in CampaignEmail model
6. **User submits response** → Response service creates response for moderation
## Database Models
### Campaign Model
See [Campaign Model Documentation](../../database/models/campaign.md) for full schema.
**Key Fields:**
- `status`: DRAFT | ACTIVE | PAUSED | ARCHIVED
- `targetGovernmentLevels`: Array of government levels (federal, provincial, municipal)
- `emailSubjectTemplate`: Subject line with {{VAR}} placeholders
- `emailBodyTemplate`: Email body with {{VAR}} placeholders
- `coverPhotoUrl`: Campaign hero image URL
- `slug`: 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](../../database/models/campaign-email.md) — Tracks sent emails
- [Response](../../database/models/response.md) — Public responses to campaign
- [Representative](../../database/models/representative.md) — Email recipients
## API Endpoints
### Admin Endpoints
See [Campaigns Module API Reference](../../backend/modules/campaigns.md#endpoints) 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](../../backend/modules/campaigns.md#public-endpoints).
| 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):
```typescript
{
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:**
1. Navigate to **Influence > Campaigns**
2. Click **Create Campaign** button
3. 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)
4. Upload cover photo (optional)
5. Click **Save** (saves as DRAFT)
**Code Example (CampaignsPage.tsx):**
```typescript
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:**
1. Click **Edit** on campaign row
2. Scroll to **Feature Flags** section
3. 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
4. Click **Save**
**Best Practices:**
- Enable `requireEmailVerification` for public response walls
- Disable `allowCustomMessage` if you want consistent messaging
- Use `highlightCampaign` sparingly (max 2-3 campaigns)
- Enable `showProgressBar` to encourage participation
### 3. Test Campaign
[Screenshot: Campaign preview with test email form]
**Steps:**
1. Set campaign status to **ACTIVE**
2. Navigate to public campaign page: `/campaigns/{slug}`
3. Enter test postal code
4. Review representative lookup results
5. Send test email to your own email address
6. 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:**
1. Return to **Campaigns** page
2. Click **Status** dropdown on campaign row
3. Select **ACTIVE**
4. Campaign now visible on public campaigns page
**Status Lifecycle:**
```mermaid
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:**
1. Click **View Emails** on campaign row
2. Review email stats:
- Total sent
- Success rate
- Failed emails
3. View individual email details (recipient, status, sent date)
4. 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:**
1. User visits `/campaigns`
2. Sees featured campaigns (if `highlightCampaign` enabled)
3. Browses active campaigns grid
4. Clicks campaign card to view details
**Code Example (CampaignsListPage.tsx):**
```typescript
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:**
1. User clicks campaign card
2. Navigated to `/campaigns/{slug}`
3. Reads campaign description
4. Enters postal code in lookup form
5. System fetches representatives from Represent API
6. User selects representatives to email
### 3. Send Email
[Screenshot: Email form with representative selection]
**User Journey:**
1. User reviews list of representatives
2. Selects representatives to email (checkboxes)
3. Reviews email subject and body
4. Edits message if `allowCustomMessage` enabled
5. Adds personal details (name, email)
6. Clicks **Send Email**
7. Email jobs added to BullMQ queue
8. User sees confirmation message
**Code Example (CampaignPage.tsx):**
```typescript
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:**
1. After sending email, user clicks **Share Your Response**
2. Navigated to `/responses/{campaignId}/submit`
3. Fills in response form:
- Type (EMAIL, LETTER, PHONE_CALL, etc.)
- Message
- Screenshot (optional)
4. Submits response
5. If `requireEmailVerification` enabled → verification email sent
6. User clicks verification link in email
7. 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
```typescript
// 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
```typescript
// 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:**
1. Check campaign status → must be `ACTIVE`
2. Verify no draft campaigns leaked → filter by status in query
3. Check Nginx caching → clear cache or disable for `/api/public/campaigns`
**Debugging:**
```bash
# 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:**
1. Verify variable syntax → must use double curly braces `{{VAR}}`
2. Check email service interpolation → ensure `processTemplate()` called
3. Verify variable names match → `senderName`, `senderEmail`, `postalCode`, `recipientName`, `recipientEmail`
**Code Fix (email.service.ts):**
```typescript
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:**
1. Check file size → max 10MB
2. Verify file format → must be jpg/jpeg/png/gif/webp
3. Check upload directory permissions → `/uploads/campaigns` must be writable
4. Increase Nginx upload limit → `client_max_body_size 20M;`
**Docker Volume Fix:**
```yaml
# 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:**
1. Check Represent API status → visit https://represent.opennorth.ca/health
2. Verify postal code format → must be valid Canadian postal code (K1A 0A1)
3. Check representative cache → may need refresh
4. Review API rate limits → Represent API has rate limits
**Manual Cache Refresh:**
```bash
# 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:**
```typescript
// 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:**
```typescript
// 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_size` metric
- Alert if queue size > 1000
- Monitor worker processing rate
### Cover Photo Optimization
**Image Processing:**
```typescript
// 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](../../backend/modules/campaigns.md) — Full API reference
- [Representatives Module](../../backend/modules/representatives.md) — Represent API integration
- [Responses Module](../../backend/modules/responses.md) — Response wall system
- [Email Queue Module](../../backend/modules/email-queue.md) — BullMQ email processing
### Frontend Pages
- [CampaignsPage](../../frontend/pages/admin/campaigns-page.md) — Admin campaign management
- [CampaignPage](../../frontend/pages/public/campaign-page.md) — Public campaign view
- [CampaignsListPage](../../frontend/pages/public/campaigns-list-page.md) — Public campaign listing
- [ResponsesPage](../../frontend/pages/admin/responses-page.md) — Response moderation
### Database Models
- [Campaign](../../database/models/campaign.md) — Campaign schema
- [CampaignEmail](../../database/models/campaign-email.md) — Email tracking schema
- [Response](../../database/models/response.md) — Response schema
- [Representative](../../database/models/representative.md) — Representative schema
### Configuration
- [Environment Variables](../../getting-started/configuration.md#email-settings) — SMTP configuration
- [Site Settings](../../backend/modules/settings.md) — Global settings API
### Guides
- [Email Sending Guide](../influence/email-queue.md) — Email queue and BullMQ
- [Response Wall Guide](../influence/responses.md) — Response moderation workflow
- [Representative Lookup Guide](../influence/representatives.md) — Represent API integration