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:

  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 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:

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:

  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):

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:

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):

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):

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

// 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:

  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:

# 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):

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:

# 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:

# 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_size metric
  • 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

Backend Modules

Frontend Pages

Database Models

Configuration

Guides