539 lines
15 KiB
Markdown
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
|