6.5 KiB
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
representativestable - TTL: 30 days (check
cachedAtfield) - 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 } },
});
Related Documentation
- Schema Reference — Complete field listings
- Database Overview — ER diagram
- API Influence Routes — REST endpoints
- Admin Campaigns Page — Campaign management UI
- Public Campaign Page — Public-facing campaign UI