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 });