6.5 KiB

Influence Models

Overview

The Influence module provides advocacy campaign management with multi-government-level targeting, email/call tracking, response wall with moderation, and representative caching.

Models (10):

  • Campaign — Advocacy campaigns with 12 feature flags
  • Representative — Cached rep data from Represent API
  • CampaignEmail — Email tracking (SMTP vs MAILTO)
  • RepresentativeResponse — Response wall submissions
  • ResponseUpvote — Upvote tracking with deduplication
  • CustomRecipient — Custom email targets
  • PostalCodeCache — Postal code geocoding cache
  • EmailLog — Email audit trail
  • EmailVerification — Email verification tokens
  • Call — Phone call tracking

Key Features:

  • Multi-government-level targeting (Federal, Provincial, Municipal, School Board)
  • Dual email methods: SMTP (async BullMQ queue) + mailto: links
  • Response moderation workflow (PENDING → APPROVED/REJECTED)
  • Email verification for response wall submissions
  • Upvote deduplication (user ID + IP address)
  • Represent API integration for Canadian representatives
  • Postal code → representative lookup

See Schema Reference for complete field listings.


Campaign Feature Flags (12 total)

Flag Default Description
allowSmtpEmail true Enable SMTP email sending via BullMQ
allowMailtoLink true Enable mailto: links for client-side email
collectUserInfo true Collect sender name/email/postal code
showEmailCount true Display email sent count on public page
showCallCount true Display call made count on public page
allowEmailEditing false Allow users to edit email subject/body
allowCustomRecipients false Enable custom recipient management
showResponseWall false Enable public response wall
highlightCampaign false Highlight on campaigns list page

Government Level Targeting

enum GovernmentLevel {
  FEDERAL
  PROVINCIAL
  MUNICIPAL
  SCHOOL_BOARD
}

Campaigns can target multiple levels:

const campaign = await prisma.campaign.create({
  data: {
    title: 'Support Climate Action',
    targetGovernmentLevels: [GovernmentLevel.FEDERAL, GovernmentLevel.PROVINCIAL],
    // ...
  },
});

Representative lookup filters by targeted levels:

const reps = await representativeService.lookup(postalCode, campaign.targetGovernmentLevels);

Email Methods

SMTP (Async Queue)

  • Queued via BullMQ (Redis backend)
  • Worker sends via Nodemailer
  • Supports templates with variable interpolation
  • Tracks delivery status (QUEUED → SENT/FAILED)
  • Rate limiting (10 emails/min per IP)

MAILTO (Client-Side)

  • Generates mailto: link with pre-filled subject/body
  • Tracked when link clicked (status: CLICKED)
  • No server-side email sending
  • User's default email client used

Response Moderation Workflow

stateDiagram-v2
    [*] --> PENDING : Submit response
    PENDING --> APPROVED : Admin approves
    PENDING --> REJECTED : Admin rejects
    APPROVED --> [*]
    REJECTED --> [*]

Status: PENDING (default) → APPROVED | REJECTED

Admin moderation via /app/influence/responses:

  • Filter by status, campaign, date range
  • Bulk approve/reject
  • View submitter details
  • Screenshot attachments

Upvote Deduplication

Two unique constraints prevent duplicate upvotes:

model ResponseUpvote {
  @@unique([responseId, userId])      // Logged-in users
  @@unique([responseId, upvotedIp])  // Guest users
}

Logic:

  • Logged-in user: Check [responseId, userId]
  • Guest user: Check [responseId, upvotedIp]
  • Database-level enforcement (no race conditions)

Represent API Integration

Representative Cache:

  • Cached in representatives table
  • TTL: 30 days (check cachedAt field)
  • Re-fetched if cache miss or stale

Lookup Flow:

sequenceDiagram
    participant Client
    participant API
    participant Cache
    participant Represent

    Client->>API: GET /api/representatives/lookup?postalCode=K1A0B1
    API->>Cache: findMany({ where: { postalCode } })
    alt Cache hit (cachedAt < 30 days ago)
        Cache-->>API: representatives[]
        API-->>Client: representatives[]
    else Cache miss or stale
        API->>Represent: GET /representatives/?point=K1A0B1
        Represent-->>API: representatives[]
        API->>Cache: upsert({ postalCode, ... })
        API-->>Client: representatives[]
    end

Common Queries

Create Campaign

const campaign = await prisma.campaign.create({
  data: {
    slug: 'climate-action',
    title: 'Support Climate Action Bill C-12',
    emailSubject: 'Support Climate Action',
    emailBody: 'I urge you to support...',
    status: CampaignStatus.ACTIVE,
    targetGovernmentLevels: [GovernmentLevel.FEDERAL],
    allowSmtpEmail: true,
    showResponseWall: true,
    createdByUserId: user.id,
  },
});

Queue Campaign Email (SMTP)

await emailQueueService.addCampaignEmail({
  campaignId: campaign.id,
  recipientEmail: 'rep@example.com',
  recipientName: 'Hon. Jane Smith',
  subject: 'Support Climate Action',
  message: 'I urge you to...',
  userEmail: 'voter@example.com',
  userName: 'John Voter',
  userPostalCode: 'K1A0B1',
});

Submit Response

const response = await prisma.representativeResponse.create({
  data: {
    campaignId: campaign.id,
    campaignSlug: campaign.slug,
    representativeName: 'Hon. Jane Smith',
    representativeLevel: GovernmentLevel.FEDERAL,
    responseType: ResponseType.EMAIL,
    responseText: 'Thank you for your letter...',
    submittedByUserId: user.id,
    submittedByEmail: 'voter@example.com',
    status: ResponseStatus.PENDING,
  },
});

Upvote Response

await prisma.responseUpvote.create({
  data: {
    responseId: response.id,
    userId: user?.id,  // Null for guests
    userEmail: user?.email,
    upvotedIp: req.ip,
  },
});

// Increment upvote count (denormalized)
await prisma.representativeResponse.update({
  where: { id: response.id },
  data: { upvoteCount: { increment: 1 } },
});