539 lines
15 KiB
Markdown

# 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 <token>" \
"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<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):**
```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<Campaign[]>([]);
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