diff --git a/README.md b/README.md
index 93278d9..4442c0f 100644
--- a/README.md
+++ b/README.md
@@ -13,6 +13,7 @@ Changemaker Lite is a streamlined documentation and development platform featuri
- **n8n**: Workflow automation and service integration
- **NocoDB**: No-code database platform and smart spreadsheet interface
- **Map**: Interactive map visualization for geographic data with real-time geolocation, walk sheet generation, and QR code integration
+- **Influence**: Campaign tool for connecting Alberta residents with elected representatives at all government levels
## Quick Start
@@ -45,6 +46,24 @@ cd map
docker compose up -d
```
+## Influence
+
+The Influence Campaign Tool helps Alberta residents connect with elected representatives at federal, provincial, and municipal levels. Users can look up representatives by postal code and send advocacy emails through customizable campaigns.
+
+Detailed setup and configuration instructions are available in the `influence/README.MD` file.
+
+### Quick Start for Influence
+
+Configure your environment and start the service:
+
+```bash
+cd influence
+cp example.env .env
+# Edit .env with your NocoDB and SMTP settings
+./scripts/build-nocodb.sh # Set up database tables
+docker compose up -d
+```
+
## Service Access
After starting, access services at:
@@ -57,6 +76,7 @@ After starting, access services at:
- **n8n**: http://localhost:5678
- **NocoDB**: http://localhost:8090
- **Map Viewer**: http://localhost:3000
+- **Influence Campaign Tool**: http://localhost:3333
## Production Deployment
diff --git a/influence/README.MD b/influence/README.MD
index 546c64d..748395d 100644
--- a/influence/README.MD
+++ b/influence/README.MD
@@ -8,6 +8,9 @@ A comprehensive web application that helps Alberta residents connect with their
- **Multi-Level Government**: Displays federal MPs, provincial MLAs, and municipal representatives
- **Contact Information**: Shows photos, email addresses, phone numbers, and office locations
- **Direct Email**: Built-in email composer to contact representatives
+- **Campaign Management**: Create and manage advocacy campaigns with customizable settings
+- **Public Campaigns Grid**: Homepage display of all active campaigns for easy discovery and participation
+- **Email Count Display**: Optional engagement metrics showing total emails sent per campaign
- **Smart Caching**: Fast performance with NocoDB caching and graceful fallback to live API
- **Responsive Design**: Works seamlessly on desktop and mobile devices
- **Real-time Data**: Integrates with Represent OpenNorth API for up-to-date information
@@ -190,6 +193,111 @@ RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100
```
+## Campaign Management Guide
+
+### Creating a Campaign
+
+1. **Access Admin Panel**: Navigate to `/admin.html` and log in with admin credentials
+2. **Create New Campaign**: Click "Create Campaign" button
+3. **Configure Basic Settings**:
+ - **Campaign Title**: Short, descriptive name (becomes the URL slug)
+ - **Description**: Brief overview shown on the campaign landing page
+ - **Call to Action**: Motivational message encouraging participation
+
+4. **Set Email Template**:
+ - **Email Subject**: Pre-filled subject line for emails
+ - **Email Body**: Default message template (users may edit if allowed)
+
+5. **Upload Cover Photo** (Optional):
+ - Click "Choose File" to upload a hero image
+ - Supported formats: JPEG, PNG, GIF, WebP
+ - Maximum size: 5MB
+ - Image displays as campaign page banner
+
+6. **Configure Campaign Settings**:
+ - **📧 Allow SMTP Email**: Enable server-side email sending
+ - **🔗 Allow Mailto Link**: Enable browser-based mailto: links
+ - **👤 Collect User Info**: Request user name and email
+ - **📊 Show Email Count**: Display total emails sent (engagement metric)
+ - **✏️ Allow Email Editing**: Let users customize email template
+ - **🎯 Target Government Levels**: Select Federal, Provincial, Municipal, School Board
+
+7. **Set Campaign Status**:
+ - **Draft**: Hidden from public, testing mode
+ - **Active**: Visible to public on main page
+ - **Paused**: Temporarily disabled
+ - **Archived**: Completed campaigns
+
+8. **Save Campaign**: Click "Create Campaign" to publish
+
+### Public Campaigns Display
+
+The homepage automatically displays all active campaigns in a responsive grid below the representative lookup section.
+
+**Features**:
+- **Automatic Display**: Only active campaigns (status="active") are shown publicly
+- **Campaign Cards**: Each campaign displays as an attractive card with:
+ - Cover photo (if uploaded) or gradient background
+ - Campaign title and truncated description
+ - Target government level badges (Federal, Provincial, Municipal, etc.)
+ - Email count badge (if enabled via campaign settings)
+ - "Learn More & Participate" call-to-action
+- **Responsive Grid**: Automatically adjusts columns based on screen size
+ - Desktop: 3-4 columns
+ - Tablet: 2 columns
+ - Mobile: 1 column
+- **Click Navigation**: Users can click any campaign card to visit the full campaign page
+- **Smart Loading**: Shows loading state while fetching campaigns, gracefully hides section if no active campaigns exist
+- **Security**: HTML content is escaped to prevent XSS attacks
+- **Sorting**: Campaigns display newest first by creation date
+
+**Public API Endpoint**: `/api/public/campaigns` (no authentication required)
+- Returns only campaigns with `status='active'`
+- Includes email counts when `show_email_count=true`
+- Optimized for performance with minimal data transfer
+
+### Email Count Display Feature
+
+The **Show Email Count** setting controls whether campaign pages display total engagement metrics.
+
+**When Enabled** (✅ checked):
+- Campaign page shows: "X Albertans have sent emails through this campaign"
+- Provides social proof and encourages participation
+- Updates in real-time as users send emails
+- Displays prominently above the call-to-action
+
+**When Disabled** (❌ unchecked):
+- Email count section is hidden
+- Useful for sensitive campaigns or privacy concerns
+- Engagement metrics still tracked in admin panel
+
+**Best Practices**:
+- ✅ Enable for public awareness campaigns to show momentum
+- ✅ Enable for volunteer recruitment to demonstrate support
+- ❌ Disable for personal advocacy or sensitive issues
+- ❌ Disable for new campaigns until participation grows
+
+**Technical Details**:
+- Count includes all successfully sent emails via campaign
+- Tracks both SMTP-sent and mailto-initiated emails (if logged)
+- Admin panel always shows counts regardless of public display setting
+- Database field: `show_email_count` (checkbox, default: true)
+
+### Editing Campaigns
+
+1. Navigate to Admin Panel → "Campaigns" tab
+2. Find campaign card and click "Edit"
+3. Modify any settings including the email count display toggle
+4. Save changes - updates apply immediately to public-facing page
+
+### Campaign Analytics
+
+Access campaign performance metrics in the Admin Panel:
+- Total emails sent per campaign
+- User participation rates
+- Email delivery status
+- Representative contact distribution
+
## API Endpoints
### Representatives
@@ -211,12 +319,28 @@ RATE_LIMIT_MAX_REQUESTS=100
## Database Schema
+### Campaigns Table
+- slug, title, description
+- email_subject, email_body
+- call_to_action, cover_photo
+- status (draft/active/paused/archived)
+- allow_smtp_email, allow_mailto_link
+- collect_user_info, **show_email_count**
+- allow_email_editing
+- target_government_levels (MultiSelect)
+- created_by_user_id, created_by_user_email, created_by_user_name
+
+### Campaign Emails Table
+- campaign_id, user_name, user_email, user_postal_code
+- recipient_name, recipient_email, recipient_level
+- subject, message, status, sent_at
+
### Representatives Table
- postal_code, name, email, district_name
- elected_office, party_name, representative_set_name
- url, photo_url, cached_at
-### Emails Table
+### Email Logs Table
- recipient_email, recipient_name, sender_email
- subject, message, status, sent_at
@@ -224,6 +348,11 @@ RATE_LIMIT_MAX_REQUESTS=100
- postal_code, city, province
- centroid_lat, centroid_lng, last_updated
+### Users Table
+- email, password_hash, name
+- role (admin/user), status (active/temporary)
+- expires_at, last_login
+
## Development
### Project Structure
@@ -267,6 +396,16 @@ influence/
- Party affiliation and government level
- Direct links to official profiles
+### Campaign System
+- **Campaign Creation**: Create advocacy campaigns with custom titles, descriptions, and email templates
+- **Cover Photos**: Upload hero images for campaign landing pages (JPEG/PNG/GIF/WebP, max 5MB)
+- **Flexible Email Methods**: Choose between SMTP email or mailto links for user convenience
+- **User Info Collection**: Optional name/email collection for campaign tracking
+- **Email Count Display**: Show total engagement metrics on campaign pages (toggle on/off)
+- **Email Editing**: Allow users to customize campaign email templates (optional)
+- **Target Levels**: Select which government levels to target (Federal/Provincial/Municipal/School Board)
+- **Campaign Status**: Draft, Active, Paused, or Archived workflow states
+
### Email Integration
- Modal-based email composer
- Pre-filled recipient information
diff --git a/influence/app/controllers/campaigns.js b/influence/app/controllers/campaigns.js
index cfce03a..e184c0e 100644
--- a/influence/app/controllers/campaigns.js
+++ b/influence/app/controllers/campaigns.js
@@ -90,6 +90,82 @@ async function cacheRepresentatives(postalCode, representatives, representData)
}
class CampaignsController {
+ // Get public campaigns (no authentication required)
+ async getPublicCampaigns(req, res, next) {
+ try {
+ const campaigns = await nocoDB.getAllCampaigns();
+
+ // Filter to only active campaigns and normalize data structure
+ const activeCampaigns = await Promise.all(
+ campaigns
+ .filter(campaign => {
+ const status = normalizeStatus(campaign['Status'] || campaign.status);
+ return status === 'active';
+ })
+ .map(async (campaign) => {
+ const id = campaign.ID || campaign.Id || campaign.id;
+
+ // Debug: Log specific fields we're looking for
+ console.log(`Campaign ${id}:`, {
+ 'Show Call Count': campaign['Show Call Count'],
+ 'show_call_count': campaign.show_call_count,
+ 'Show Email Count': campaign['Show Email Count'],
+ 'show_email_count': campaign.show_email_count
+ });
+
+ // Get email count if show_email_count is enabled
+ let emailCount = null;
+ const showEmailCount = campaign['Show Email Count'] || campaign.show_email_count;
+ console.log(`Getting email count for campaign ID: ${id}, showEmailCount: ${showEmailCount}`);
+ if (showEmailCount && id != null) {
+ emailCount = await nocoDB.getCampaignEmailCount(id);
+ console.log(`Email count result: ${emailCount}`);
+ }
+
+ // Get call count if show_call_count is enabled
+ let callCount = null;
+ const showCallCount = campaign['Show Call Count'] || campaign.show_call_count;
+ console.log(`Getting call count for campaign ID: ${id}, showCallCount: ${showCallCount}`);
+ if (showCallCount && id != null) {
+ callCount = await nocoDB.getCampaignCallCount(id);
+ console.log(`Call count result: ${callCount}`);
+ }
+
+ const rawTargetLevels = campaign['Target Government Levels'] || campaign.target_government_levels;
+ const normalizedTargetLevels = normalizeTargetLevels(rawTargetLevels);
+
+ // Return only public-facing information
+ return {
+ id,
+ slug: campaign['Campaign Slug'] || campaign.slug,
+ title: campaign['Campaign Title'] || campaign.title,
+ description: campaign['Description'] || campaign.description,
+ call_to_action: campaign['Call to Action'] || campaign.call_to_action,
+ cover_photo: campaign['Cover Photo'] || campaign.cover_photo,
+ show_email_count: showEmailCount,
+ show_call_count: showCallCount,
+ target_government_levels: normalizedTargetLevels,
+ created_at: campaign.CreatedAt || campaign.created_at,
+ emailCount,
+ callCount
+ };
+ })
+ );
+
+ res.json({
+ success: true,
+ campaigns: activeCampaigns
+ });
+ } catch (error) {
+ console.error('Get public campaigns error:', error);
+ res.status(500).json({
+ success: false,
+ error: 'Failed to retrieve campaigns',
+ message: error.message
+ });
+ }
+ }
+
// Get all campaigns (for admin panel)
async getAllCampaigns(req, res, next) {
try {
@@ -249,9 +325,23 @@ class CampaignsController {
let emailCount = null;
const showEmailCount = campaign['Show Email Count'] || campaign.show_email_count;
if (showEmailCount) {
- const id = campaign.Id ?? campaign.id;
+ const id = campaign.ID || campaign.Id || campaign.id;
+ console.log('Getting email count for campaign ID:', id);
if (id != null) {
emailCount = await nocoDB.getCampaignEmailCount(id);
+ console.log('Email count result:', emailCount);
+ }
+ }
+
+ // Get call count if enabled
+ let callCount = null;
+ const showCallCount = campaign['Show Call Count'] || campaign.show_call_count;
+ if (showCallCount) {
+ const id = campaign.ID || campaign.Id || campaign.id;
+ console.log('Getting call count for campaign ID:', id);
+ if (id != null) {
+ callCount = await nocoDB.getCampaignCallCount(id);
+ console.log('Call count result:', callCount);
}
}
@@ -274,9 +364,11 @@ class CampaignsController {
allow_mailto_link: campaign['Allow Mailto Link'] || campaign.allow_mailto_link,
collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info,
show_email_count: campaign['Show Email Count'] || campaign.show_email_count,
+ show_call_count: campaign['Show Call Count'] || campaign.show_call_count,
allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing,
target_government_levels: normalizeTargetLevels(campaign['Target Government Levels'] || campaign.target_government_levels),
- emailCount
+ emailCount,
+ callCount
}
});
} catch (error) {
@@ -419,6 +511,10 @@ class CampaignsController {
}
}
+ // Track old slug for cascade updates
+ const oldSlug = existingCampaign['Campaign Slug'] || existingCampaign.slug;
+ let newSlug = oldSlug;
+
if (updates.title) {
let slug = generateSlug(updates.title);
@@ -433,6 +529,7 @@ class CampaignsController {
}
}
updates.slug = slug;
+ newSlug = slug;
}
if (updates.target_government_levels !== undefined) {
@@ -472,6 +569,19 @@ class CampaignsController {
const campaign = await nocoDB.updateCampaign(id, updates);
+ // If slug changed, update references in related tables
+ if (oldSlug && newSlug && oldSlug !== newSlug) {
+ console.log(`Campaign slug changed from '${oldSlug}' to '${newSlug}', updating references...`);
+ const cascadeResult = await nocoDB.updateCampaignSlugReferences(id, oldSlug, newSlug);
+
+ if (cascadeResult.success) {
+ console.log(`Successfully updated slug references: ${cascadeResult.updatedCampaignEmails} campaign emails, ${cascadeResult.updatedCallLogs} call logs`);
+ } else {
+ console.warn(`Failed to update some slug references:`, cascadeResult.error);
+ // Don't fail the main update - cascade is a best-effort operation
+ }
+ }
+
res.json({
success: true,
campaign: {
@@ -892,6 +1002,74 @@ class CampaignsController {
});
}
}
+
+ // Track campaign phone call
+ async trackCampaignCall(req, res, next) {
+ try {
+ const { slug } = req.params;
+ const {
+ representativeName,
+ representativeTitle,
+ phoneNumber,
+ officeType,
+ userEmail,
+ userName,
+ postalCode
+ } = req.body;
+
+ // Validate required fields
+ if (!representativeName || !phoneNumber) {
+ return res.status(400).json({
+ success: false,
+ error: 'Representative name and phone number are required'
+ });
+ }
+
+ // Get campaign
+ const campaign = await nocoDB.getCampaignBySlug(slug);
+ if (!campaign) {
+ return res.status(404).json({
+ success: false,
+ error: 'Campaign not found'
+ });
+ }
+
+ const campaignStatus = normalizeStatus(campaign['Status'] || campaign.status);
+ if (campaignStatus !== 'active') {
+ return res.status(403).json({
+ success: false,
+ error: 'Campaign is not currently active'
+ });
+ }
+
+ // Log the call
+ await nocoDB.logCall({
+ representativeName,
+ representativeTitle: representativeTitle || null,
+ phoneNumber,
+ officeType: officeType || null,
+ callerName: userName || null,
+ callerEmail: userEmail || null,
+ postalCode: postalCode || null,
+ campaignId: campaign.ID || campaign.Id || campaign.id,
+ campaignSlug: slug,
+ callerIP: req.ip || req.connection?.remoteAddress || null,
+ timestamp: new Date().toISOString()
+ });
+
+ res.json({
+ success: true,
+ message: 'Call tracked successfully'
+ });
+ } catch (error) {
+ console.error('Track campaign call error:', error);
+ res.status(500).json({
+ success: false,
+ error: 'Failed to track call',
+ message: error.message
+ });
+ }
+ }
}
// Export controller instance and upload middleware
diff --git a/influence/app/controllers/representatives.js b/influence/app/controllers/representatives.js
index 675cd37..845889d 100644
--- a/influence/app/controllers/representatives.js
+++ b/influence/app/controllers/representatives.js
@@ -180,6 +180,55 @@ class RepresentativesController {
});
}
}
+
+ async trackCall(req, res, next) {
+ try {
+ const {
+ representativeName,
+ representativeTitle,
+ phoneNumber,
+ officeType,
+ userEmail,
+ userName,
+ postalCode
+ } = req.body;
+
+ // Validate required fields
+ if (!representativeName || !phoneNumber) {
+ return res.status(400).json({
+ success: false,
+ error: 'Representative name and phone number are required'
+ });
+ }
+
+ // Log the call
+ await nocoDB.logCall({
+ representativeName,
+ representativeTitle: representativeTitle || null,
+ phoneNumber,
+ officeType: officeType || null,
+ callerName: userName || null,
+ callerEmail: userEmail || null,
+ postalCode: postalCode || null,
+ campaignId: null,
+ campaignSlug: null,
+ callerIP: req.ip || req.connection?.remoteAddress || null,
+ timestamp: new Date().toISOString()
+ });
+
+ res.json({
+ success: true,
+ message: 'Call tracked successfully'
+ });
+ } catch (error) {
+ console.error('Track call error:', error);
+ res.status(500).json({
+ success: false,
+ error: 'Failed to track call',
+ message: error.message
+ });
+ }
+ }
}
module.exports = new RepresentativesController();
\ No newline at end of file
diff --git a/influence/app/public/campaign.html b/influence/app/public/campaign.html
index b50ac60..598e674 100644
--- a/influence/app/public/campaign.html
+++ b/influence/app/public/campaign.html
@@ -300,10 +300,18 @@
-
+
-
0
-
Albertans have sent emails through this campaign
+
diff --git a/influence/app/public/css/styles.css b/influence/app/public/css/styles.css
index decce29..c231c81 100644
--- a/influence/app/public/css/styles.css
+++ b/influence/app/public/css/styles.css
@@ -1064,4 +1064,359 @@ footer a:hover {
overflow: hidden;
text-overflow: ellipsis;
}
-}
\ No newline at end of file
+}
+
+/* ===================================
+ CAMPAIGNS GRID STYLES
+ =================================== */
+
+#campaigns-section {
+ margin-top: 60px;
+ padding-top: 40px;
+ border-top: 2px solid #e0e0e0;
+}
+
+.campaigns-section-header {
+ text-align: center;
+ margin-bottom: 40px;
+}
+
+.campaigns-section-header h2 {
+ color: #005a9c;
+ font-size: 2em;
+ margin-bottom: 10px;
+}
+
+.campaigns-section-header p {
+ color: #666;
+ font-size: 1.1em;
+}
+
+#campaigns-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
+ gap: 30px;
+ margin: 0 auto;
+}
+
+.campaign-card {
+ background: white;
+ border-radius: 12px;
+ overflow: hidden;
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+ transition: all 0.3s ease;
+ cursor: pointer;
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ outline: none;
+}
+
+.campaign-card:hover,
+.campaign-card:focus {
+ transform: translateY(-5px);
+ box-shadow: 0 8px 20px rgba(0, 90, 156, 0.2);
+}
+
+.campaign-card:focus {
+ outline: 2px solid #005a9c;
+ outline-offset: 2px;
+}
+
+.campaign-card-image {
+ width: 100%;
+ height: 200px;
+ position: relative;
+ overflow: hidden;
+}
+
+.campaign-card-overlay {
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.3);
+ transition: background 0.3s ease;
+}
+
+.campaign-card:hover .campaign-card-overlay {
+ background: rgba(0, 0, 0, 0.4);
+}
+
+.campaign-card-content {
+ padding: 20px;
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+}
+
+.campaign-card-title {
+ color: #005a9c;
+ font-size: 1.4em;
+ margin-bottom: 12px;
+ font-weight: 600;
+ line-height: 1.3;
+}
+
+.campaign-card-description {
+ color: #555;
+ font-size: 0.95em;
+ line-height: 1.6;
+ margin-bottom: 16px;
+ flex: 1;
+}
+
+.campaign-card-levels {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ margin-bottom: 12px;
+}
+
+.level-badge {
+ background: #e8f4f8;
+ color: #005a9c;
+ padding: 4px 10px;
+ border-radius: 12px;
+ font-size: 0.8em;
+ font-weight: 500;
+}
+
+.campaign-card-stats {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ margin-bottom: 16px;
+}
+
+@media (min-width: 768px) {
+ .campaign-card-stats {
+ flex-direction: row;
+ gap: 12px;
+ }
+
+ .campaign-card-stat {
+ flex: 1;
+ margin-bottom: 0;
+ }
+}
+
+.campaign-card-stat {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 12px;
+ background: #f8f9fa;
+ border-radius: 8px;
+}
+
+.stat-icon {
+ font-size: 1.2em;
+}
+
+.stat-value {
+ font-size: 1.3em;
+ font-weight: 700;
+ color: #005a9c;
+}
+
+.stat-label {
+ font-size: 0.85em;
+ color: #666;
+}
+
+.campaign-card-social-share {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 12px 0;
+ margin-bottom: 8px;
+ flex-wrap: wrap;
+}
+
+.share-label {
+ font-size: 0.85em;
+ color: #666;
+ font-weight: 500;
+ margin-right: 4px;
+}
+
+.share-btn {
+ width: 32px;
+ height: 32px;
+ border: none;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ background: #f0f0f0;
+ color: #666;
+ padding: 6px;
+}
+
+.share-btn svg {
+ width: 18px;
+ height: 18px;
+}
+
+.share-btn:hover {
+ transform: scale(1.1);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
+}
+
+.share-btn:focus {
+ outline: 2px solid #005a9c;
+ outline-offset: 2px;
+}
+
+.share-twitter:hover {
+ background: #000000;
+ color: white;
+}
+
+.share-facebook:hover {
+ background: #1877f2;
+ color: white;
+}
+
+.share-linkedin:hover {
+ background: #0077b5;
+ color: white;
+}
+
+.share-reddit:hover {
+ background: #ff4500;
+ color: white;
+}
+
+.share-email:hover {
+ background: #005a9c;
+ color: white;
+}
+
+.share-copy:hover {
+ background: #34a853;
+ color: white;
+}
+
+.share-feedback {
+ position: fixed;
+ bottom: 20px;
+ right: 20px;
+ background: #34a853;
+ color: white;
+ padding: 12px 20px;
+ border-radius: 8px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+ font-size: 0.9em;
+ font-weight: 500;
+ opacity: 0;
+ transform: translateY(20px);
+ transition: all 0.3s ease;
+ z-index: 10000;
+ pointer-events: none;
+}
+
+.share-feedback.show {
+ opacity: 1;
+ transform: translateY(0);
+}
+
+.share-feedback.error {
+ background: #dc3545;
+}
+
+.campaign-card-action {
+ margin-top: auto;
+ padding-top: 12px;
+ border-top: 1px solid #e0e0e0;
+}
+
+.btn-link {
+ color: #005a9c;
+ font-weight: 600;
+ font-size: 0.95em;
+ text-decoration: none;
+ transition: color 0.2s ease;
+}
+
+.campaign-card:hover .btn-link {
+ color: #004a7c;
+}
+
+.campaigns-loading,
+.campaigns-error,
+.campaigns-empty {
+ text-align: center;
+ padding: 60px 20px;
+ color: #666;
+}
+
+.campaigns-loading .spinner {
+ width: 50px;
+ height: 50px;
+ border: 4px solid #f3f3f3;
+ border-top: 4px solid #005a9c;
+ border-radius: 50%;
+ animation: spin 1s linear infinite;
+ margin: 0 auto 20px;
+}
+
+.campaigns-error {
+ color: #d32f2f;
+}
+
+.campaigns-empty {
+ background: #f8f9fa;
+ border-radius: 8px;
+ padding: 40px;
+}
+
+/* Responsive campaign grid styles */
+@media (max-width: 1024px) {
+ #campaigns-grid {
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+ gap: 24px;
+ }
+}
+
+@media (max-width: 768px) {
+ #campaigns-section {
+ margin-top: 40px;
+ padding-top: 30px;
+ }
+
+ .campaigns-section-header h2 {
+ font-size: 1.75em;
+ }
+
+ #campaigns-grid {
+ grid-template-columns: 1fr;
+ gap: 20px;
+ }
+
+ .campaign-card-image {
+ height: 180px;
+ }
+}
+
+@media (max-width: 480px) {
+ .campaigns-section-header h2 {
+ font-size: 1.5em;
+ }
+
+ .campaign-card-title {
+ font-size: 1.2em;
+ }
+
+ .campaign-card-image {
+ height: 160px;
+ }
+
+ .campaign-card-content {
+ padding: 16px;
+ }
+}
diff --git a/influence/app/public/index.html b/influence/app/public/index.html
index 7438a19..69ef37a 100644
--- a/influence/app/public/index.html
+++ b/influence/app/public/index.html
@@ -179,12 +179,28 @@
+
+
+
© 2025 BNKops Influence Tool. Connect with democracy.
This tool uses the Represent API by Open North to find your representatives.
Terms of Use & Privacy Notice
+
+
+
@@ -198,6 +214,7 @@
+
diff --git a/influence/app/public/js/admin.js b/influence/app/public/js/admin.js
index 8a198b0..04c1b68 100644
--- a/influence/app/public/js/admin.js
+++ b/influence/app/public/js/admin.js
@@ -553,6 +553,7 @@ class AdminPanel {
campaignFormData.append('allow_mailto_link', formData.get('allow_mailto_link') === 'on');
campaignFormData.append('collect_user_info', formData.get('collect_user_info') === 'on');
campaignFormData.append('show_email_count', formData.get('show_email_count') === 'on');
+ campaignFormData.append('allow_email_editing', formData.get('allow_email_editing') === 'on');
// Handle target_government_levels array
const targetLevels = Array.from(formData.getAll('target_government_levels'));
@@ -659,6 +660,7 @@ class AdminPanel {
updateFormData.append('allow_mailto_link', formData.get('allow_mailto_link') === 'on');
updateFormData.append('collect_user_info', formData.get('collect_user_info') === 'on');
updateFormData.append('show_email_count', formData.get('show_email_count') === 'on');
+ updateFormData.append('allow_email_editing', formData.get('allow_email_editing') === 'on');
// Handle target_government_levels array
const targetLevels = Array.from(formData.getAll('target_government_levels'));
diff --git a/influence/app/public/js/campaign.js b/influence/app/public/js/campaign.js
index dd93842..baa44dc 100644
--- a/influence/app/public/js/campaign.js
+++ b/influence/app/public/js/campaign.js
@@ -75,10 +75,26 @@ class CampaignPage {
headerElement.style.backgroundImage = '';
}
- // Show email count if enabled
- if (this.campaign.show_email_count && this.campaign.emailCount !== null) {
+ // Show email count if enabled (show even if count is 0)
+ const statsSection = document.getElementById('campaign-stats');
+ let hasStats = false;
+
+ if (this.campaign.show_email_count && this.campaign.emailCount !== null && this.campaign.emailCount !== undefined) {
document.getElementById('email-count').textContent = this.campaign.emailCount;
- document.getElementById('campaign-stats').style.display = 'block';
+ document.getElementById('email-count-container').style.display = 'block';
+ hasStats = true;
+ }
+
+ // Show call count if enabled (show even if count is 0)
+ if (this.campaign.show_call_count && this.campaign.callCount !== null && this.campaign.callCount !== undefined) {
+ document.getElementById('call-count').textContent = this.campaign.callCount;
+ document.getElementById('call-count-container').style.display = 'block';
+ hasStats = true;
+ }
+
+ // Show stats section if any stat is enabled
+ if (hasStats) {
+ statsSection.style.display = 'block';
}
// Show call to action
@@ -461,21 +477,26 @@ class CampaignPage {
async trackCall(phone, name, title, officeType) {
try {
- await fetch(`/api/campaigns/${this.campaignSlug}/track-call`, {
+ const response = await fetch(`/api/campaigns/${this.campaignSlug}/track-call`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
+ representativeName: name,
+ representativeTitle: title || '',
+ phoneNumber: phone,
+ officeType: officeType || '',
userEmail: this.userInfo.userEmail,
userName: this.userInfo.userName,
- postalCode: this.userInfo.postalCode,
- recipientPhone: phone,
- recipientName: name,
- recipientTitle: title,
- officeType: officeType
+ postalCode: this.userInfo.postalCode
})
});
+
+ const data = await response.json();
+ if (data.success) {
+ this.showCallSuccess('Call tracked successfully!');
+ }
} catch (error) {
console.error('Failed to track call:', error);
}
@@ -627,6 +648,18 @@ class CampaignPage {
// You could show a toast or update UI to indicate success
alert(message); // Simple for now, could be improved with better UI
}
+
+ showCallSuccess(message) {
+ // Update call count if enabled
+ if (this.campaign.show_call_count) {
+ const countElement = document.getElementById('call-count');
+ const currentCount = parseInt(countElement.textContent) || 0;
+ countElement.textContent = currentCount + 1;
+ }
+
+ // Show success message
+ alert(message);
+ }
}
// Initialize the campaign page when DOM is loaded
diff --git a/influence/app/public/js/campaigns-grid.js b/influence/app/public/js/campaigns-grid.js
new file mode 100644
index 0000000..48e9592
--- /dev/null
+++ b/influence/app/public/js/campaigns-grid.js
@@ -0,0 +1,326 @@
+// Campaigns Grid Module
+// Displays public campaigns in a responsive grid on the homepage
+
+class CampaignsGrid {
+ constructor() {
+ this.campaigns = [];
+ this.container = null;
+ this.loading = false;
+ this.error = null;
+ }
+
+ async init() {
+ this.container = document.getElementById('campaigns-grid');
+ if (!this.container) {
+ console.error('Campaigns grid container not found');
+ return;
+ }
+
+ await this.loadCampaigns();
+ }
+
+ async loadCampaigns() {
+ if (this.loading) return;
+
+ this.loading = true;
+ this.showLoading();
+
+ try {
+ const response = await fetch('/api/public/campaigns');
+ const data = await response.json();
+
+ if (!data.success) {
+ throw new Error(data.error || 'Failed to load campaigns');
+ }
+
+ this.campaigns = data.campaigns || [];
+ this.renderCampaigns();
+
+ // Show or hide the entire campaigns section based on availability
+ const campaignsSection = document.getElementById('campaigns-section');
+ if (this.campaigns.length > 0) {
+ campaignsSection.style.display = 'block';
+ } else {
+ campaignsSection.style.display = 'none';
+ }
+ } catch (error) {
+ console.error('Error loading campaigns:', error);
+ this.showError('Unable to load campaigns. Please try again later.');
+ } finally {
+ this.loading = false;
+ }
+ }
+
+ renderCampaigns() {
+ if (!this.container) return;
+
+ if (this.campaigns.length === 0) {
+ this.container.innerHTML = `
+
+
No active campaigns at the moment. Check back soon!
+
+ `;
+ return;
+ }
+
+ // Sort campaigns by created_at date (newest first)
+ const sortedCampaigns = [...this.campaigns].sort((a, b) => {
+ const dateA = new Date(a.created_at || 0);
+ const dateB = new Date(b.created_at || 0);
+ return dateB - dateA;
+ });
+
+ const campaignsHTML = sortedCampaigns.map(campaign => this.renderCampaignCard(campaign)).join('');
+ this.container.innerHTML = campaignsHTML;
+
+ // Add click event listeners to campaign cards (no inline handlers)
+ this.attachCardClickHandlers();
+ }
+
+ attachCardClickHandlers() {
+ const campaignCards = this.container.querySelectorAll('.campaign-card');
+ campaignCards.forEach(card => {
+ const slug = card.getAttribute('data-slug');
+ if (slug) {
+ // Handle card click (but not share buttons)
+ card.addEventListener('click', (e) => {
+ // Don't navigate if clicking on share buttons
+ if (e.target.closest('.share-btn') || e.target.closest('.campaign-card-social-share')) {
+ return;
+ }
+ window.location.href = `/campaign/${slug}`;
+ });
+
+ // Add keyboard accessibility
+ card.setAttribute('tabindex', '0');
+ card.setAttribute('role', 'link');
+ card.setAttribute('aria-label', `View campaign: ${card.querySelector('.campaign-card-title')?.textContent || 'campaign'}`);
+
+ card.addEventListener('keypress', (e) => {
+ if (e.target.closest('.share-btn')) {
+ return; // Let share buttons handle their own keyboard events
+ }
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ window.location.href = `/campaign/${slug}`;
+ }
+ });
+
+ // Attach share button handlers
+ const shareButtons = card.querySelectorAll('.share-btn');
+ shareButtons.forEach(btn => {
+ btn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ e.preventDefault();
+ const platform = btn.getAttribute('data-platform');
+ const title = card.querySelector('.campaign-card-title')?.textContent || 'Campaign';
+ const description = card.querySelector('.campaign-card-description')?.textContent || '';
+ this.handleShare(platform, slug, title, description);
+ });
+ });
+ }
+ });
+ }
+
+ renderCampaignCard(campaign) {
+ const coverPhotoStyle = campaign.cover_photo
+ ? `background-image: url('/uploads/${campaign.cover_photo}'); background-size: cover; background-position: center;`
+ : 'background: linear-gradient(135deg, #3498db, #2c3e50);';
+
+ const emailCountBadge = campaign.show_email_count && campaign.emailCount !== null
+ ? `
+ 📧
+ ${campaign.emailCount}
+ emails sent
+
`
+ : '';
+
+ const callCountBadge = campaign.show_call_count && campaign.callCount !== null
+ ? `
+ 📞
+ ${campaign.callCount}
+ calls made
+
`
+ : '';
+
+ const targetLevels = Array.isArray(campaign.target_government_levels) && campaign.target_government_levels.length > 0
+ ? campaign.target_government_levels.map(level => `${level} `).join('')
+ : '';
+
+ // Truncate description to reasonable length
+ const description = campaign.description || '';
+ const truncatedDescription = description.length > 150
+ ? description.substring(0, 150) + '...'
+ : description;
+
+ return `
+
+
+
+
${this.escapeHtml(campaign.title)}
+
${this.escapeHtml(truncatedDescription)}
+ ${targetLevels ? `
${targetLevels}
` : ''}
+
+ ${emailCountBadge}
+ ${callCountBadge}
+
+
+
Share:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Learn More & Participate →
+
+
+
+ `;
+ }
+
+ showLoading() {
+ if (!this.container) return;
+
+ this.container.innerHTML = `
+
+
+
Loading campaigns...
+
+ `;
+ }
+
+ showError(message) {
+ if (!this.container) return;
+
+ this.container.innerHTML = `
+
+
⚠️ ${this.escapeHtml(message)}
+
+ `;
+ }
+
+ escapeHtml(text) {
+ const map = {
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": '''
+ };
+ return text.replace(/[&<>"']/g, (m) => map[m]);
+ }
+
+ handleShare(platform, slug, title, description) {
+ const campaignUrl = `${window.location.origin}/campaign/${slug}`;
+ const shareText = `${title} - ${description}`;
+
+ let shareUrl = '';
+
+ switch(platform) {
+ case 'twitter':
+ shareUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent(shareText)}&url=${encodeURIComponent(campaignUrl)}`;
+ window.open(shareUrl, '_blank', 'width=550,height=420');
+ break;
+
+ case 'facebook':
+ shareUrl = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(campaignUrl)}`;
+ window.open(shareUrl, '_blank', 'width=550,height=420');
+ break;
+
+ case 'linkedin':
+ shareUrl = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(campaignUrl)}`;
+ window.open(shareUrl, '_blank', 'width=550,height=420');
+ break;
+
+ case 'reddit':
+ shareUrl = `https://www.reddit.com/submit?url=${encodeURIComponent(campaignUrl)}&title=${encodeURIComponent(title)}`;
+ window.open(shareUrl, '_blank', 'width=550,height=420');
+ break;
+
+ case 'email':
+ const emailSubject = `Check out this campaign: ${title}`;
+ const emailBody = `${shareText}\n\nLearn more and participate: ${campaignUrl}`;
+ window.location.href = `mailto:?subject=${encodeURIComponent(emailSubject)}&body=${encodeURIComponent(emailBody)}`;
+ break;
+
+ case 'copy':
+ this.copyToClipboard(campaignUrl);
+ break;
+ }
+ }
+
+ async copyToClipboard(text) {
+ try {
+ if (navigator.clipboard && window.isSecureContext) {
+ await navigator.clipboard.writeText(text);
+ } else {
+ // Fallback for older browsers
+ const textArea = document.createElement('textarea');
+ textArea.value = text;
+ textArea.style.position = 'fixed';
+ textArea.style.left = '-999999px';
+ document.body.appendChild(textArea);
+ textArea.select();
+ document.execCommand('copy');
+ document.body.removeChild(textArea);
+ }
+ this.showShareFeedback('Link copied to clipboard!');
+ } catch (err) {
+ console.error('Failed to copy:', err);
+ this.showShareFeedback('Failed to copy link', true);
+ }
+ }
+
+ showShareFeedback(message, isError = false) {
+ // Create or get feedback element
+ let feedback = document.getElementById('share-feedback');
+ if (!feedback) {
+ feedback = document.createElement('div');
+ feedback.id = 'share-feedback';
+ feedback.className = 'share-feedback';
+ document.body.appendChild(feedback);
+ }
+
+ feedback.textContent = message;
+ feedback.className = `share-feedback ${isError ? 'error' : 'success'} show`;
+
+ // Auto-hide after 3 seconds
+ setTimeout(() => {
+ feedback.classList.remove('show');
+ }, 3000);
+ }
+}
+
+// Export for use in main.js
+if (typeof window !== 'undefined') {
+ window.CampaignsGrid = CampaignsGrid;
+}
diff --git a/influence/app/public/js/main.js b/influence/app/public/js/main.js
index 118ceb7..6d14543 100644
--- a/influence/app/public/js/main.js
+++ b/influence/app/public/js/main.js
@@ -157,6 +157,12 @@ window.Utils = Utils;
document.addEventListener('DOMContentLoaded', () => {
window.mainApp = new MainApp();
+ // Initialize campaigns grid
+ if (typeof CampaignsGrid !== 'undefined') {
+ window.campaignsGrid = new CampaignsGrid();
+ window.campaignsGrid.init();
+ }
+
// Add some basic accessibility improvements
document.addEventListener('keydown', (e) => {
// Allow Escape to close modals (handled in individual modules)
diff --git a/influence/app/public/js/postal-lookup.js b/influence/app/public/js/postal-lookup.js
index 2273d59..0d94fbf 100644
--- a/influence/app/public/js/postal-lookup.js
+++ b/influence/app/public/js/postal-lookup.js
@@ -111,6 +111,10 @@ class PostalLookup {
const data = await window.apiClient.getRepresentativesByPostalCode(postalCode);
this.currentPostalCode = postalCode;
+
+ // Store postal code globally for call tracking
+ window.lastLookupPostalCode = postalCode;
+
this.displayResults(data);
} catch (error) {
diff --git a/influence/app/public/js/representatives-display.js b/influence/app/public/js/representatives-display.js
index f1dd8bd..383f8fc 100644
--- a/influence/app/public/js/representatives-display.js
+++ b/influence/app/public/js/representatives-display.js
@@ -427,6 +427,32 @@ class RepresentativesDisplay {
if (confirm(message)) {
// Attempt to initiate the call
window.location.href = telLink;
+
+ // Track the call
+ this.trackCall(phone, name, office, officeType);
+ }
+ }
+
+ async trackCall(phone, name, office, officeType) {
+ try {
+ await fetch('/api/track-call', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ representativeName: name,
+ representativeTitle: office || '',
+ phoneNumber: phone,
+ officeType: officeType || '',
+ postalCode: window.lastLookupPostalCode || null,
+ userEmail: null,
+ userName: null
+ })
+ });
+ } catch (error) {
+ console.error('Failed to track call:', error);
+ // Don't show error to user - tracking is non-critical
}
}
diff --git a/influence/app/routes/api.js b/influence/app/routes/api.js
index 9b97dab..903276a 100644
--- a/influence/app/routes/api.js
+++ b/influence/app/routes/api.js
@@ -140,6 +140,7 @@ router.put(
router.get('/campaigns/:id/analytics', requireAuth, rateLimiter.general, campaignsController.getCampaignAnalytics);
// Campaign endpoints (Public)
+router.get('/public/campaigns', rateLimiter.general, campaignsController.getPublicCampaigns);
router.get('/campaigns/:slug', rateLimiter.general, campaignsController.getCampaignBySlug);
router.get('/campaigns/:slug/representatives/:postalCode', rateLimiter.representAPI, campaignsController.getRepresentativesForCampaign);
router.post(
@@ -164,6 +165,30 @@ router.post(
campaignsController.sendCampaignEmail
);
+// Campaign call tracking endpoint
+router.post(
+ '/campaigns/:slug/track-call',
+ rateLimiter.general,
+ [
+ body('representativeName').notEmpty().withMessage('Representative name is required'),
+ body('phoneNumber').notEmpty().withMessage('Phone number is required')
+ ],
+ handleValidationErrors,
+ campaignsController.trackCampaignCall
+);
+
+// General call tracking endpoint (non-campaign)
+router.post(
+ '/track-call',
+ rateLimiter.general,
+ [
+ body('representativeName').notEmpty().withMessage('Representative name is required'),
+ body('phoneNumber').notEmpty().withMessage('Phone number is required')
+ ],
+ handleValidationErrors,
+ representativesController.trackCall
+);
+
// User management routes (admin only)
router.use('/admin/users', userRoutes);
diff --git a/influence/app/services/nocodb.js b/influence/app/services/nocodb.js
index 2190fb0..ea7a593 100644
--- a/influence/app/services/nocodb.js
+++ b/influence/app/services/nocodb.js
@@ -25,7 +25,8 @@ class NocoDBService {
postalCodes: process.env.NOCODB_TABLE_POSTAL_CODES,
campaigns: process.env.NOCODB_TABLE_CAMPAIGNS,
campaignEmails: process.env.NOCODB_TABLE_CAMPAIGN_EMAILS,
- users: process.env.NOCODB_TABLE_USERS
+ users: process.env.NOCODB_TABLE_USERS,
+ calls: process.env.NOCODB_TABLE_CALLS
};
// Validate that all table IDs are set
@@ -381,7 +382,9 @@ class NocoDBService {
async getAllCampaigns() {
try {
const response = await this.getAll(this.tableIds.campaigns, {
- sort: '-CreatedAt'
+ sort: '-CreatedAt',
+ // Explicitly request all fields to ensure newly added columns are included
+ fields: '*'
});
return response.list || [];
} catch (error) {
@@ -543,6 +546,24 @@ class NocoDBService {
}
}
+ async getCampaignCallCount(campaignId) {
+ try {
+ if (!this.tableIds.calls) {
+ console.warn('Calls table not configured, returning 0');
+ return 0;
+ }
+
+ const response = await this.getAll(this.tableIds.calls, {
+ where: `(Campaign ID,eq,${campaignId})`,
+ limit: 1000 // Get enough to count
+ });
+ return response.pageInfo ? response.pageInfo.totalRows : (response.list ? response.list.length : 0);
+ } catch (error) {
+ console.error('Get campaign call count failed:', error);
+ return 0;
+ }
+ }
+
async getCampaignAnalytics(campaignId) {
try {
const response = await this.getAll(this.tableIds.campaignEmails, {
diff --git a/influence/files-explainer.md b/influence/files-explainer.md
index 3d9f112..f840e56 100644
--- a/influence/files-explainer.md
+++ b/influence/files-explainer.md
@@ -6,6 +6,109 @@ This document explains the purpose and functionality of each file in the BNKops
The BNKops Influence Campaign Tool is a comprehensive political engagement platform that allows users to create and participate in advocacy campaigns, contact their elected representatives, and track campaign analytics. The application features campaign management, user authentication, representative lookup via the Represent API, email composition and sending, and detailed analytics tracking.
+## Campaign Settings System
+
+The application includes a flexible campaign configuration system that allows administrators to customize campaign behavior and appearance. Campaign settings control user experience, data collection, and engagement tracking.
+
+### Core Campaign Settings
+
+**Email Count Display** (`show_email_count`):
+- **Purpose**: Controls visibility of total emails sent metric on campaign landing pages
+- **Implementation**: Checkbox field in campaigns table (default: true)
+- **When Enabled**: Shows "X Albertans have sent emails through this campaign" banner
+- **When Disabled**: Hides public email count while maintaining admin tracking
+- **Use Cases**:
+ - ✅ Enable for public campaigns to demonstrate momentum and social proof
+ - ❌ Disable for sensitive campaigns or when starting with low participation
+- **Database**: `show_email_count` BOOLEAN field in campaigns table
+- **Backend**: `getCampaignEmailCount()` fetches count when setting is enabled
+- **Frontend**: `campaign.js` shows/hides `campaign-stats` div based on setting
+- **Admin Panel**: Checkbox labeled "📊 Show Email Count" in create/edit forms
+
+**SMTP Email** (`allow_smtp_email`):
+- Enables server-side email sending through SMTP configuration
+- When enabled, emails sent via backend with full logging and tracking
+
+**Mailto Links** (`allow_mailto_link`):
+- Allows browser-based email client launching via mailto: URLs
+- Useful fallback when SMTP isn't configured or for user preference
+
+**User Info Collection** (`collect_user_info`):
+- When enabled, requests user's name and email before showing representatives
+- Supports campaign tracking and follow-up communications
+
+**Email Editing** (`allow_email_editing`):
+- Allows users to customize email subject and body before sending
+- When disabled, forces use of campaign template only
+
+**Cover Photo** (`cover_photo`):
+- Upload custom hero images for campaign landing pages
+- Supported formats: JPEG, PNG, GIF, WebP (max 5MB)
+- Displays as full-width background with overlay on campaign pages
+
+**Target Government Levels** (`target_government_levels`):
+- MultiSelect field for Federal, Provincial, Municipal, School Board
+- Filters which representatives are displayed to campaign participants
+
+**Campaign Status** (`status`):
+- Draft: Hidden from public, testing only
+- Active: Visible on main page and accessible via slug URL
+- Paused: Temporarily disabled
+- Archived: Completed campaigns, read-only
+
+### Technical Implementation
+
+**Database Schema** (`build-nocodb.sh`):
+```javascript
+{
+ "column_name": "show_email_count",
+ "title": "Show Email Count",
+ "uidt": "Checkbox",
+ "cdf": "true" // Default enabled
+}
+```
+
+**Backend API** (`campaigns.js`):
+- `getCampaignBySlug()`: Checks `show_email_count` setting and conditionally fetches email count
+- `getAllCampaigns()`: Includes email count for all campaigns (admin view)
+- `getCampaignEmailCount()`: Queries campaign_emails table for total sent emails
+
+**Frontend Display** (`campaign.js`):
+```javascript
+if (this.campaign.show_email_count && this.campaign.emailCount !== null) {
+ document.getElementById('email-count').textContent = this.campaign.emailCount;
+ document.getElementById('campaign-stats').style.display = 'block';
+}
+```
+
+**Admin Interface** (`admin.html`):
+- Create form: Checkbox with `id="create-show-count"` and `name="show_email_count"`
+- Edit form: Checkbox with `id="edit-show-count"` pre-populated from campaign data
+- Default state: Checked (enabled) for new campaigns
+
+**Service Layer** (`nocodb.js`):
+- Field mapping: `'Show Email Count': campaignData.show_email_count`
+- Create/Update operations: Properly formats boolean value for NocoDB API
+- Read operations: Normalizes field from NocoDB title to JavaScript property name
+
+### Campaign Email Count Tracking
+
+The email count feature tracks engagement across campaign participation. The system counts emails sent through:
+- SMTP-based email sending (fully tracked with delivery status)
+- Campaign email logs in NocoDB campaign_emails table
+- Associated with specific campaign IDs for accurate attribution
+
+**Count Calculation**:
+- Queries campaign_emails table with `(Campaign ID,eq,{campaignId})` filter
+- Returns total count of matching records
+- Updates in real-time as users participate
+- Cached in campaign response for performance
+
+**Display Logic**:
+- Only shows count when both `show_email_count=true` AND `emailCount > 0`
+- Handles null/undefined counts gracefully
+- Updates without page refresh using dynamic JavaScript rendering
+
## Authentication System
The application includes a complete authentication system supporting both admin and regular user access. Authentication is implemented using NocoDB as the user database, bcryptjs for password hashing, and express-session for session management. The system supports temporary users with expiration dates for campaign-specific access.
@@ -67,6 +170,7 @@ Business logic layer that handles HTTP requests and responses:
- Updates last login timestamps and manages session persistence
- **`campaigns.js`** - Core campaign management functionality with comprehensive CRUD operations
+ - `getPublicCampaigns()` - **Public endpoint** retrieves only active campaigns without authentication, returns filtered data with email counts if enabled
- `getAllCampaigns()` - Retrieves campaigns with filtering, pagination, and user permissions
- `createCampaign()` - Creates new campaigns with validation, slug generation, and cover photo upload handling using multer
- `updateCampaign()` - Updates campaign details, status management, and cover photo processing
@@ -116,6 +220,11 @@ API endpoint definitions and request validation:
- **`api.js`** - Main API routes with extensive validation middleware
- Campaign management endpoints: CRUD operations for campaigns, participation, analytics
+ - GET `/api/public/campaigns` - **Public endpoint** (no auth required) returns all active campaigns with email counts if enabled
+ - GET `/api/campaigns/:slug` - Public campaign lookup by URL slug for campaign landing pages
+ - GET `/api/campaigns/:slug/representatives/:postalCode` - Get representatives for a campaign by postal code
+ - POST `/api/campaigns/:slug/track-user` - Track user participation in campaigns
+ - POST `/api/campaigns/:slug/send-email` - Send campaign emails to representatives
- Representative endpoints with postal code validation and caching
- Email endpoints with input sanitization, template support, and test mode
- Email management: `/api/emails/preview`, `/api/emails/send`, `/api/emails/logs`, `/api/emails/test`
@@ -356,6 +465,19 @@ Professional HTML and text email templates with variable substitution:
- Progress tracking through campaign participation workflow
- Social sharing and engagement tracking functionality
+- **`campaigns-grid.js`** - Public campaigns grid display for homepage
+ - `CampaignsGrid` class for displaying active campaigns in a responsive card layout
+ - Fetches public campaigns via `/api/public/campaigns` endpoint (no authentication required)
+ - Dynamic grid rendering with automatic responsive columns
+ - Campaign card generation with cover photos, titles, descriptions, and engagement stats
+ - Email count display when enabled via campaign settings
+ - Target government level badges for filtering context
+ - Click-to-navigate functionality to individual campaign pages
+ - Loading states and error handling with user-friendly messages
+ - Automatic show/hide of campaigns section based on availability
+ - HTML escaping for security against XSS attacks
+ - Sort campaigns by creation date (newest first)
+
- **`dashboard.js`** - User dashboard and analytics interface
- `UserDashboard` class managing personalized user experience
- Campaign management interface for user-created campaigns
diff --git a/influence/fix-campaigns-table.sh b/influence/fix-campaigns-table.sh
deleted file mode 100644
index 73d648d..0000000
--- a/influence/fix-campaigns-table.sh
+++ /dev/null
@@ -1,296 +0,0 @@
-#!/bin/bash
-
-# Fix Campaigns Table Script
-# This script recreates the campaigns table with proper column options
-
-set -e # Exit on any error
-
-# Colors for output
-RED='\033[0;31m'
-GREEN='\033[0;32m'
-YELLOW='\033[1;33m'
-BLUE='\033[0;34m'
-NC='\033[0m' # No Color
-
-# Function to print colored output
-print_status() {
- echo -e "${BLUE}$1${NC}"
-}
-
-print_success() {
- echo -e "${GREEN}$1${NC}"
-}
-
-print_warning() {
- echo -e "${YELLOW}$1${NC}"
-}
-
-print_error() {
- echo -e "${RED}$1${NC}"
-}
-
-# Load environment variables
-if [ -f ".env" ]; then
- export $(cat .env | grep -v '^#' | xargs)
- print_success "Environment variables loaded from .env"
-else
- print_error "No .env file found. Please create one based on .env.example"
- exit 1
-fi
-
-# Validate required environment variables
-if [ -z "$NOCODB_API_URL" ] || [ -z "$NOCODB_API_TOKEN" ] || [ -z "$NOCODB_PROJECT_ID" ]; then
- print_error "Missing required environment variables: NOCODB_API_URL, NOCODB_API_TOKEN, NOCODB_PROJECT_ID"
- exit 1
-fi
-
-print_status "Using NocoDB instance: $NOCODB_API_URL"
-print_status "Project ID: $NOCODB_PROJECT_ID"
-
-# Function to make API calls with proper error handling
-make_api_call() {
- local method="$1"
- local url="$2"
- local data="$3"
- local description="$4"
-
- print_status "Making $method request to: $url"
- if [ -n "$description" ]; then
- print_status "Purpose: $description"
- fi
-
- local response
- local http_code
-
- if [ "$method" = "DELETE" ]; then
- response=$(curl -s -w "\n%{http_code}" -X DELETE \
- -H "xc-token: $NOCODB_API_TOKEN" \
- -H "Content-Type: application/json" \
- "$url")
- elif [ "$method" = "POST" ] && [ -n "$data" ]; then
- response=$(curl -s -w "\n%{http_code}" -X POST \
- -H "xc-token: $NOCODB_API_TOKEN" \
- -H "Content-Type: application/json" \
- -d "$data" \
- "$url")
- else
- print_error "Invalid method or missing data for API call"
- return 1
- fi
-
- # Extract HTTP code and response body
- http_code=$(echo "$response" | tail -n1)
- response_body=$(echo "$response" | head -n -1)
-
- print_status "HTTP Status: $http_code"
-
- if [[ "$http_code" -ge 200 && "$http_code" -lt 300 ]]; then
- print_success "API call successful"
- echo "$response_body"
- return 0
- else
- print_error "API call failed with status $http_code"
- print_error "Response: $response_body"
- return 1
- fi
-}
-
-# Function to delete the campaigns table
-delete_campaigns_table() {
- local table_id="$1"
-
- print_warning "Deleting existing campaigns table (ID: $table_id)..."
-
- make_api_call "DELETE" \
- "$NOCODB_API_URL/db/meta/tables/$table_id" \
- "" \
- "Delete campaigns table" > /dev/null
-}
-
-# Function to create the campaigns table with proper options
-create_campaigns_table() {
- local base_id="$1"
-
- print_status "Creating new campaigns table..."
-
- local table_data='{
- "table_name": "influence_campaigns",
- "title": "Campaigns",
- "columns": [
- {
- "column_name": "id",
- "title": "ID",
- "uidt": "ID",
- "pk": true,
- "ai": true,
- "rqd": true
- },
- {
- "column_name": "slug",
- "title": "Campaign Slug",
- "uidt": "SingleLineText",
- "unique": true,
- "rqd": true
- },
- {
- "column_name": "title",
- "title": "Campaign Title",
- "uidt": "SingleLineText",
- "rqd": true
- },
- {
- "column_name": "description",
- "title": "Description",
- "uidt": "LongText"
- },
- {
- "column_name": "email_subject",
- "title": "Email Subject",
- "uidt": "SingleLineText",
- "rqd": true
- },
- {
- "column_name": "email_body",
- "title": "Email Body",
- "uidt": "LongText",
- "rqd": true
- },
- {
- "column_name": "call_to_action",
- "title": "Call to Action",
- "uidt": "LongText"
- },
- {
- "column_name": "status",
- "title": "Status",
- "uidt": "SingleSelect",
- "colOptions": {
- "options": [
- {"title": "draft", "color": "#cfdffe"},
- {"title": "active", "color": "#c2f5e8"},
- {"title": "paused", "color": "#fee2d5"},
- {"title": "archived", "color": "#ffeab6"}
- ]
- },
- "rqd": true,
- "cdf": "draft"
- },
- {
- "column_name": "allow_smtp_email",
- "title": "Allow SMTP Email",
- "uidt": "Checkbox",
- "cdf": "true"
- },
- {
- "column_name": "allow_mailto_link",
- "title": "Allow Mailto Link",
- "uidt": "Checkbox",
- "cdf": "true"
- },
- {
- "column_name": "collect_user_info",
- "title": "Collect User Info",
- "uidt": "Checkbox",
- "cdf": "true"
- },
- {
- "column_name": "show_email_count",
- "title": "Show Email Count",
- "uidt": "Checkbox",
- "cdf": "true"
- },
- {
- "column_name": "target_government_levels",
- "title": "Target Government Levels",
- "uidt": "MultiSelect",
- "colOptions": {
- "options": [
- {"title": "Federal", "color": "#cfdffe"},
- {"title": "Provincial", "color": "#d0f1fd"},
- {"title": "Municipal", "color": "#c2f5e8"},
- {"title": "School Board", "color": "#ffdaf6"}
- ]
- }
- },
- {
- "column_name": "created_at",
- "title": "Created At",
- "uidt": "DateTime",
- "cdf": "now()"
- },
- {
- "column_name": "updated_at",
- "title": "Updated At",
- "uidt": "DateTime",
- "cdf": "now()"
- }
- ]
- }'
-
- local response
- response=$(make_api_call "POST" \
- "$NOCODB_API_URL/db/meta/bases/$base_id/tables" \
- "$table_data" \
- "Create campaigns table with proper column options")
-
- if [ $? -eq 0 ]; then
- # Extract table ID from response
- local table_id=$(echo "$response" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
- if [ -n "$table_id" ]; then
- print_success "New campaigns table created with ID: $table_id"
- echo "$table_id"
- return 0
- else
- print_error "Could not extract table ID from response"
- return 1
- fi
- else
- return 1
- fi
-}
-
-# Main execution
-print_status "Starting campaigns table fix..."
-
-# Check if campaigns table exists
-if [ -n "$NOCODB_TABLE_CAMPAIGNS" ]; then
- print_status "Found existing campaigns table ID: $NOCODB_TABLE_CAMPAIGNS"
-
- # Delete existing table
- if delete_campaigns_table "$NOCODB_TABLE_CAMPAIGNS"; then
- print_success "Successfully deleted old campaigns table"
- else
- print_warning "Failed to delete old table, continuing anyway..."
- fi
-fi
-
-# Create new table
-NEW_TABLE_ID=$(create_campaigns_table "$NOCODB_PROJECT_ID")
-if [ $? -eq 0 ] && [ -n "$NEW_TABLE_ID" ]; then
- print_success "Successfully created new campaigns table!"
-
- # Update .env file with new table ID
- print_status "Updating .env file with new table ID..."
-
- if grep -q "NOCODB_TABLE_CAMPAIGNS=" .env; then
- # Replace existing NOCODB_TABLE_CAMPAIGNS
- sed -i "s/NOCODB_TABLE_CAMPAIGNS=.*/NOCODB_TABLE_CAMPAIGNS=$NEW_TABLE_ID/" .env
- print_success "Updated NOCODB_TABLE_CAMPAIGNS in .env file"
- else
- # Add new NOCODB_TABLE_CAMPAIGNS
- echo "NOCODB_TABLE_CAMPAIGNS=$NEW_TABLE_ID" >> .env
- print_success "Added NOCODB_TABLE_CAMPAIGNS to .env file"
- fi
-
- print_status ""
- print_status "============================================================"
- print_success "Campaigns table fix completed successfully!"
- print_status "============================================================"
- print_status ""
- print_status "New table ID: $NEW_TABLE_ID"
- print_status "Please restart your application to use the new table."
-
-else
- print_error "Failed to create new campaigns table"
- exit 1
-fi
\ No newline at end of file
diff --git a/influence/influence-campaign-setup.md b/influence/influence-campaign-setup.md
deleted file mode 100644
index 054c311..0000000
--- a/influence/influence-campaign-setup.md
+++ /dev/null
@@ -1,1288 +0,0 @@
-# BNKops Influence Campaign Tool - Complete Setup Guide
-
-## Project Overview
-A locally-hosted political influence campaign tool for Alberta constituents to contact their representatives via email. Uses the Represent OpenNorth API for representative data and provides both self-service and campaign modes.
-
-## Directory Structure
-```
-influence-campaign/
-├── docker-compose.yml
-├── .env.example
-├── app/
-│ ├── Dockerfile
-│ ├── package.json
-│ ├── server.js
-│ ├── controllers/
-│ │ ├── representatives.js
-│ │ └── emails.js
-│ ├── routes/
-│ │ └── api.js
-│ ├── services/
-│ │ ├── nocodb.js
-│ │ ├── represent-api.js
-│ │ └── email.js
-│ ├── utils/
-│ │ ├── validators.js
-│ │ └── rate-limiter.js
-│ └── public/
-│ ├── index.html
-│ ├── css/
-│ │ └── styles.css
-│ └── js/
-│ ├── main.js
-│ ├── api-client.js
-│ └── postal-lookup.js
-├── scripts/
-│ └── build-nocodb.sh
-└── README.md
-```
-
-## 1. Docker Setup
-
-### docker-compose.yml
-```yaml
-version: '3.8'
-
-services:
- app:
- build: ./app
- ports:
- - "3000:3000"
- environment:
- - NODE_ENV=development
- - PORT=3000
- - NOCODB_URL=http://nocodb:8080
- env_file:
- - .env
- depends_on:
- - nocodb
- volumes:
- - ./app:/usr/src/app
- - /usr/src/app/node_modules
- command: npm run dev
- restart: unless-stopped
-
- nocodb:
- image: nocodb/nocodb:latest
- ports:
- - "8080:8080"
- environment:
- - NC_DB=/usr/app/data/noco.db
- - NC_AUTH_JWT_SECRET=${NOCODB_AUTH_SECRET}
- volumes:
- - nocodb-data:/usr/app/data
- restart: unless-stopped
-
-volumes:
- nocodb-data:
-```
-
-### .env.example
-```bash
-# NocoDB Configuration
-NOCODB_URL=http://nocodb:8080
-NOCODB_API_TOKEN=your_nocodb_api_token_here
-NOCODB_AUTH_SECRET=your_jwt_secret_here
-
-# SMTP Configuration (e.g., SendGrid, Gmail, etc.)
-SMTP_HOST=smtp.sendgrid.net
-SMTP_PORT=587
-SMTP_USER=apikey
-SMTP_PASS=your_smtp_password_here
-SMTP_FROM_EMAIL=noreply@yourcampaign.ca
-SMTP_FROM_NAME=BNKops Influence Campaign
-
-# Admin Configuration
-ADMIN_PASSWORD=secure_admin_password_here
-
-# Represent API
-REPRESENT_API_BASE=https://represent.opennorth.ca
-REPRESENT_API_RATE_LIMIT=60
-
-# App Configuration
-APP_URL=http://localhost:3000
-SESSION_SECRET=your_session_secret_here
-```
-
-### app/Dockerfile
-```dockerfile
-FROM node:18-alpine
-
-WORKDIR /usr/src/app
-
-# Copy package files
-COPY package*.json ./
-
-# Install dependencies
-RUN npm ci --only=production
-
-# Copy app files
-COPY . .
-
-# Expose port
-EXPOSE 3000
-
-# Start the application
-CMD ["node", "server.js"]
-```
-
-## 2. Backend Implementation
-
-### app/package.json
-```json
-{
- "name": "alberta-influence-campaign",
- "version": "1.0.0",
- "description": "Political influence campaign tool for Alberta",
- "main": "server.js",
- "scripts": {
- "start": "node server.js",
- "dev": "nodemon server.js",
- "test": "echo \"Error: no test specified\" && exit 1"
- },
- "dependencies": {
- "express": "^4.18.2",
- "cors": "^2.8.5",
- "dotenv": "^16.3.1",
- "axios": "^1.6.2",
- "nodemailer": "^6.9.7",
- "express-rate-limit": "^7.1.5",
- "helmet": "^7.1.0",
- "express-validator": "^7.0.1"
- },
- "devDependencies": {
- "nodemon": "^3.0.2"
- }
-}
-```
-
-### app/server.js
-```javascript
-const express = require('express');
-const cors = require('cors');
-const helmet = require('helmet');
-const path = require('path');
-require('dotenv').config();
-
-const apiRoutes = require('./routes/api');
-
-const app = express();
-const PORT = process.env.PORT || 3000;
-
-// Security middleware
-app.use(helmet({
- contentSecurityPolicy: {
- directives: {
- defaultSrc: ["'self'"],
- styleSrc: ["'self'", "'unsafe-inline'"],
- scriptSrc: ["'self'"],
- imgSrc: ["'self'", "https:", "data:"],
- },
- },
-}));
-
-// Middleware
-app.use(cors());
-app.use(express.json());
-app.use(express.static(path.join(__dirname, 'public')));
-
-// Routes
-app.use('/api', apiRoutes);
-
-// Error handling middleware
-app.use((err, req, res, next) => {
- console.error(err.stack);
- res.status(err.status || 500).json({
- error: {
- message: err.message || 'Internal server error',
- ...(process.env.NODE_ENV === 'development' && { stack: err.stack })
- }
- });
-});
-
-app.listen(PORT, () => {
- console.log(`Server running on port ${PORT}`);
- console.log(`Environment: ${process.env.NODE_ENV}`);
-});
-```
-
-### app/routes/api.js
-```javascript
-const express = require('express');
-const router = express.Router();
-const { body, param, validationResult } = require('express-validator');
-const representativesController = require('../controllers/representatives');
-const emailsController = require('../controllers/emails');
-const rateLimiter = require('../utils/rate-limiter');
-
-// Validation middleware
-const handleValidationErrors = (req, res, next) => {
- const errors = validationResult(req);
- if (!errors.isEmpty()) {
- return res.status(400).json({ errors: errors.array() });
- }
- next();
-};
-
-// Test endpoints
-router.get('/health', (req, res) => {
- res.json({ status: 'ok', timestamp: new Date().toISOString() });
-});
-
-router.get('/test-represent', representativesController.testConnection);
-
-// Representatives endpoints
-router.get(
- '/representatives/by-postal/:postalCode',
- param('postalCode').matches(/^[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d$/),
- handleValidationErrors,
- rateLimiter.general,
- representativesController.getByPostalCode
-);
-
-router.post(
- '/representatives/refresh-postal/:postalCode',
- param('postalCode').matches(/^[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d$/),
- handleValidationErrors,
- representativesController.refreshPostalCode
-);
-
-// Email endpoints
-router.post(
- '/emails/send',
- [
- body('from_email').isEmail(),
- body('to_email').isEmail(),
- body('subject').notEmpty().isLength({ max: 200 }),
- body('message').notEmpty().isLength({ min: 10, max: 5000 }),
- body('representative_name').notEmpty()
- ],
- handleValidationErrors,
- rateLimiter.email,
- emailsController.sendEmail
-);
-
-module.exports = router;
-```
-
-### app/services/represent-api.js
-```javascript
-const axios = require('axios');
-
-class RepresentAPIService {
- constructor() {
- this.baseURL = process.env.REPRESENT_API_BASE || 'https://represent.opennorth.ca';
- this.requestCount = 0;
- this.resetTime = Date.now() + 60000; // Reset every minute
- }
-
- async checkRateLimit() {
- if (Date.now() > this.resetTime) {
- this.requestCount = 0;
- this.resetTime = Date.now() + 60000;
- }
-
- if (this.requestCount >= 60) {
- throw new Error('Rate limit exceeded. Please wait before making more requests.');
- }
-
- this.requestCount++;
- }
-
- formatPostalCode(postalCode) {
- // Remove spaces and convert to uppercase
- return postalCode.replace(/\s/g, '').toUpperCase();
- }
-
- async getByPostalCode(postalCode) {
- try {
- await this.checkRateLimit();
-
- const formattedPostal = this.formatPostalCode(postalCode);
-
- // Validate Alberta postal code (starts with T)
- if (!formattedPostal.startsWith('T')) {
- throw new Error('Only Alberta postal codes (starting with T) are supported');
- }
-
- const response = await axios.get(`${this.baseURL}/postcodes/${formattedPostal}/`, {
- timeout: 10000,
- headers: {
- 'User-Agent': 'BNKops Influence Campaign Tool'
- }
- });
-
- return this.processRepresentatives(response.data);
- } catch (error) {
- if (error.response?.status === 404) {
- throw new Error('Postal code not found');
- }
- throw error;
- }
- }
-
- processRepresentatives(data) {
- const representatives = [];
-
- // Combine centroid and concordance representatives
- const allReps = [
- ...(data.representatives_centroid || []),
- ...(data.representatives_concordance || [])
- ];
-
- // De-duplicate based on name and elected_office
- const seen = new Set();
- const uniqueReps = allReps.filter(rep => {
- const key = `${rep.name}-${rep.elected_office}`;
- if (seen.has(key)) return false;
- seen.add(key);
- return true;
- });
-
- // Filter and categorize Alberta representatives
- uniqueReps.forEach(rep => {
- if (this.isAlbertaRepresentative(rep)) {
- representatives.push({
- ...rep,
- level: this.determineLevel(rep),
- contact_info: this.extractContactInfo(rep)
- });
- }
- });
-
- return {
- postal_code: data.code,
- city: data.city,
- province: data.province,
- representatives: this.organizeByLevel(representatives)
- };
- }
-
- isAlbertaRepresentative(rep) {
- // Federal MPs for Alberta ridings
- if (rep.elected_office === 'MP') {
- return rep.district_name && (
- rep.district_name.includes('Alberta') ||
- rep.district_name.includes('Calgary') ||
- rep.district_name.includes('Edmonton')
- );
- }
-
- // Provincial MLAs
- if (rep.elected_office === 'MLA') {
- return true;
- }
-
- // Municipal representatives
- const albertaCities = ['Calgary', 'Edmonton', 'Red Deer', 'Lethbridge',
- 'Medicine Hat', 'St. Albert', 'Airdrie', 'Spruce Grove'];
- if (rep.elected_office && ['Mayor', 'Councillor', 'Alderman'].includes(rep.elected_office)) {
- return albertaCities.some(city =>
- rep.district_name && rep.district_name.includes(city)
- );
- }
-
- return false;
- }
-
- determineLevel(rep) {
- if (rep.elected_office === 'MP') return 'federal';
- if (rep.elected_office === 'MLA') return 'provincial';
- if (['Mayor', 'Councillor', 'Alderman'].includes(rep.elected_office)) return 'municipal';
- return 'other';
- }
-
- extractContactInfo(rep) {
- const contact = {
- email: rep.email || null,
- offices: []
- };
-
- if (rep.offices && Array.isArray(rep.offices)) {
- rep.offices.forEach(office => {
- contact.offices.push({
- type: office.type || 'constituency',
- phone: office.tel || null,
- fax: office.fax || null,
- address: office.postal || null
- });
- });
- }
-
- return contact;
- }
-
- organizeByLevel(representatives) {
- return {
- federal: representatives.filter(r => r.level === 'federal'),
- provincial: representatives.filter(r => r.level === 'provincial'),
- municipal: representatives.filter(r => r.level === 'municipal')
- };
- }
-}
-
-module.exports = new RepresentAPIService();
-```
-
-### app/services/nocodb.js
-```javascript
-const axios = require('axios');
-
-class NocoDBService {
- constructor() {
- this.baseURL = process.env.NOCODB_URL;
- this.apiToken = process.env.NOCODB_API_TOKEN;
- this.projectName = 'influence_campaign';
-
- this.client = axios.create({
- baseURL: `${this.baseURL}/api/v1/db/data/v1/${this.projectName}`,
- headers: {
- 'xc-auth': this.apiToken
- },
- timeout: 10000
- });
- }
-
- // Generic methods
- async create(table, data) {
- try {
- const response = await this.client.post(`/${table}`, data);
- return response.data;
- } catch (error) {
- console.error(`Error creating record in ${table}:`, error.message);
- throw error;
- }
- }
-
- async find(table, id) {
- try {
- const response = await this.client.get(`/${table}/${id}`);
- return response.data;
- } catch (error) {
- console.error(`Error finding record in ${table}:`, error.message);
- throw error;
- }
- }
-
- async list(table, params = {}) {
- try {
- const response = await this.client.get(`/${table}`, { params });
- return response.data;
- } catch (error) {
- console.error(`Error listing records from ${table}:`, error.message);
- throw error;
- }
- }
-
- async update(table, id, data) {
- try {
- const response = await this.client.patch(`/${table}/${id}`, data);
- return response.data;
- } catch (error) {
- console.error(`Error updating record in ${table}:`, error.message);
- throw error;
- }
- }
-
- // Specific methods for postal cache
- async getCachedPostal(postalCode) {
- try {
- const results = await this.list('postal_cache', {
- where: `(postal_code,eq,${postalCode})`,
- limit: 1
- });
-
- if (results.list && results.list.length > 0) {
- const cached = results.list[0];
- const cacheAge = Date.now() - new Date(cached.cached_at).getTime();
- const maxAge = 7 * 24 * 60 * 60 * 1000; // 7 days
-
- if (cacheAge < maxAge) {
- return JSON.parse(cached.response_json);
- }
- }
-
- return null;
- } catch (error) {
- console.error('Error getting cached postal:', error.message);
- return null;
- }
- }
-
- async cachePostal(postalCode, data) {
- try {
- const existing = await this.list('postal_cache', {
- where: `(postal_code,eq,${postalCode})`,
- limit: 1
- });
-
- const cacheData = {
- postal_code: postalCode,
- response_json: JSON.stringify(data),
- cached_at: new Date().toISOString()
- };
-
- if (existing.list && existing.list.length > 0) {
- await this.update('postal_cache', existing.list[0].id, cacheData);
- } else {
- await this.create('postal_cache', cacheData);
- }
- } catch (error) {
- console.error('Error caching postal:', error.message);
- }
- }
-
- // Email logging
- async logEmail(emailData) {
- try {
- return await this.create('sent_emails', {
- from_email: emailData.from_email,
- to_email: emailData.to_email,
- representative_name: emailData.representative_name,
- subject: emailData.subject,
- body: emailData.body,
- sent_at: new Date().toISOString(),
- status: emailData.status || 'sent'
- });
- } catch (error) {
- console.error('Error logging email:', error.message);
- // Don't throw - logging failure shouldn't stop email sending
- }
- }
-}
-
-module.exports = new NocoDBService();
-```
-
-### app/services/email.js
-```javascript
-const nodemailer = require('nodemailer');
-
-class EmailService {
- constructor() {
- this.transporter = nodemailer.createTransport({
- host: process.env.SMTP_HOST,
- port: process.env.SMTP_PORT || 587,
- secure: process.env.SMTP_PORT === '465',
- auth: {
- user: process.env.SMTP_USER,
- pass: process.env.SMTP_PASS
- }
- });
- }
-
- async sendEmail({ from_email, to_email, subject, message, representative_name }) {
- try {
- const mailOptions = {
- from: `${process.env.SMTP_FROM_NAME} <${process.env.SMTP_FROM_EMAIL}>`,
- to: to_email,
- subject: subject,
- text: message,
- html: this.generateHTMLEmail(message, from_email, representative_name),
- replyTo: from_email,
- headers: {
- 'X-Sender-Email': from_email,
- 'X-Campaign': 'BNKops Influence Campaign'
- }
- };
-
- const info = await this.transporter.sendMail(mailOptions);
-
- return {
- success: true,
- messageId: info.messageId,
- response: info.response
- };
- } catch (error) {
- console.error('Email send error:', error);
- throw new Error(`Failed to send email: ${error.message}`);
- }
- }
-
- generateHTMLEmail(message, from_email, representative_name) {
- return `
-
-
-
-
-
-
-
-
-
Dear ${representative_name},
- ${message.split('\n').map(para => `
${para}
`).join('')}
-
-
-
-
- `;
- }
-
- async verifyConnection() {
- try {
- await this.transporter.verify();
- return { connected: true };
- } catch (error) {
- return { connected: false, error: error.message };
- }
- }
-}
-
-module.exports = new EmailService();
-```
-
-### app/controllers/representatives.js
-```javascript
-const representAPI = require('../services/represent-api');
-const nocoDB = require('../services/nocodb');
-
-class RepresentativesController {
- async getByPostalCode(req, res, next) {
- try {
- const { postalCode } = req.params;
-
- // Check cache first
- let data = await nocoDB.getCachedPostal(postalCode);
-
- if (!data) {
- // Fetch from API
- data = await representAPI.getByPostalCode(postalCode);
-
- // Cache the result
- await nocoDB.cachePostal(postalCode, data);
- }
-
- res.json({
- success: true,
- data,
- cached: !!data.from_cache
- });
- } catch (error) {
- next(error);
- }
- }
-
- async refreshPostalCode(req, res, next) {
- try {
- const { postalCode } = req.params;
-
- // Check admin auth
- const authHeader = req.headers.authorization;
- if (!authHeader || authHeader !== `Bearer ${process.env.ADMIN_PASSWORD}`) {
- return res.status(401).json({ error: 'Unauthorized' });
- }
-
- // Force fetch from API
- const data = await representAPI.getByPostalCode(postalCode);
-
- // Update cache
- await nocoDB.cachePostal(postalCode, data);
-
- res.json({
- success: true,
- message: 'Cache refreshed',
- data
- });
- } catch (error) {
- next(error);
- }
- }
-
- async testConnection(req, res, next) {
- try {
- // Test with Edmonton postal code
- const testPostal = 'T5J2R7';
- const data = await representAPI.getByPostalCode(testPostal);
-
- res.json({
- success: true,
- message: 'Represent API connection successful',
- test_postal: testPostal,
- representatives_found: {
- federal: data.representatives.federal.length,
- provincial: data.representatives.provincial.length,
- municipal: data.representatives.municipal.length
- }
- });
- } catch (error) {
- res.status(500).json({
- success: false,
- message: 'Represent API connection failed',
- error: error.message
- });
- }
- }
-}
-
-module.exports = new RepresentativesController();
-```
-
-### app/controllers/emails.js
-```javascript
-const emailService = require('../services/email');
-const nocoDB = require('../services/nocodb');
-
-class EmailsController {
- async sendEmail(req, res, next) {
- try {
- const { from_email, to_email, subject, message, representative_name } = req.body;
-
- // Send email
- const result = await emailService.sendEmail({
- from_email,
- to_email,
- subject,
- message,
- representative_name
- });
-
- // Log to database
- await nocoDB.logEmail({
- from_email,
- to_email,
- representative_name,
- subject,
- body: message,
- status: 'sent'
- });
-
- res.json({
- success: true,
- message: 'Email sent successfully',
- messageId: result.messageId
- });
- } catch (error) {
- // Log failed attempt
- await nocoDB.logEmail({
- ...req.body,
- status: 'failed',
- error: error.message
- });
-
- next(error);
- }
- }
-}
-
-module.exports = new EmailsController();
-```
-
-### app/utils/rate-limiter.js
-```javascript
-const rateLimit = require('express-rate-limit');
-
-// General API rate limiter
-const general = rateLimit({
- windowMs: 15 * 60 * 1000, // 15 minutes
- max: 100, // Limit each IP to 100 requests per windowMs
- message: 'Too many requests from this IP, please try again later.',
- standardHeaders: true,
- legacyHeaders: false,
-});
-
-// Email sending rate limiter
-const email = rateLimit({
- windowMs: 60 * 60 * 1000, // 1 hour
- max: 10, // Limit to 10 emails per hour per IP
- message: 'Email sending limit reached. Please wait before sending more emails.',
- standardHeaders: true,
- legacyHeaders: false,
- skipSuccessfulRequests: false,
-});
-
-module.exports = {
- general,
- email
-};
-```
-
-### app/utils/validators.js
-```javascript
-// Validate Canadian postal code format
-function validatePostalCode(postalCode) {
- const regex = /^[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d$/;
- return regex.test(postalCode);
-}
-
-// Validate Alberta postal code (starts with T)
-function validateAlbertaPostalCode(postalCode) {
- const formatted = postalCode.replace(/\s/g, '').toUpperCase();
- return formatted.startsWith('T') && validatePostalCode(postalCode);
-}
-
-// Validate email format
-function validateEmail(email) {
- const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
- return regex.test(email);
-}
-
-module.exports = {
- validatePostalCode,
- validateAlbertaPostalCode,
- validateEmail
-};
-```
-
-## 3. Frontend Implementation
-
-### app/public/index.html
-```html
-
-
-
-
-
- Contact Your Alberta Representatives
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Finding your representatives...
-
-
-
-
-
-
-
- Your Representatives
-
-
-
Federal Representatives (MPs)
-
-
-
-
-
Provincial Representatives (MLAs)
-
-
-
-
-
Municipal Representatives
-
-
-
-
-
-
-
-
-
×
-
Send Email to
-
-
-
-
-
-
-
-
-
-
-
-
-
-```
-
-### app/public/css/styles.css
-```css
-* {
- margin: 0;
- padding: 0;
- box-sizing: border-box;
-}
-
-body {
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
- line-height: 1.6;
- color: #333;
- background-color: #f5f5f5;
-}
-
-.container {
- max-width: 1200px;
- margin: 0 auto;
- padding: 20px;
-}
-
-header {
- text-align: center;
- margin-bottom: 40px;
- padding: 20px;
- background: white;
- border-radius: 8px;
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
-}
-
-header h1 {
- color: #005a9c;
- margin-bottom: 10px;
-}
-
-/* Forms */
-.form-group {
- margin-bottom: 20px;
-}
-
-.form-group label {
- display: block;
- margin-bottom: 5px;
- font-weight: 600;
- color: #555;
-}
-
-.form-group input,
-.form-group textarea {
- width: 100%;
- padding: 10px;
- border: 1px solid #ddd;
- border-radius: 4px;
- font-size: 16px;
-}
-
-.form-group input:focus,
-.form-group textarea:focus {
- outline: none;
- border-color: #005a9c;
- box-shadow: 0 0 0 2px rgba(0, 90, 156, 0.1);
-}
-
-#postal-form {
- background: white;
- padding: 30px;
- border-radius: 8px;
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
- max-width: 500px;
- margin: 0 auto;
-}
-
-#postal-form .form-group {
- display: flex;
- gap: 10px;
-}
-
-#postal-form input {
- flex: 1;
-}
-
-/* Buttons */
-.btn {
- padding: 10px 20px;
- border: none;
- border-radius: 4px;
- font-size: 16px;
- cursor: pointer;
- transition: all 0.3s ease;
-}
-
-.btn-primary {
- background-color: #005a9c;
- color: white;
-}
-
-.btn-primary:hover {
- background-color: #004a7c;
-}
-
-.btn-secondary {
- background-color: #6c757d;
- color: white;
-}
-
-.btn-secondary:hover {
- background-color: #5a6268;
-}
-
-/* Loading Spinner */
-.loading {
- text-align: center;
- padding: 40px;
-}
-
-.spinner {
- border: 4px solid #f3f3f3;
- border-top: 4px solid #005a9c;
- border-radius: 50%;
- width: 40px;
- height: 40px;
- animation: spin 1s linear infinite;
- margin: 0 auto 20px;
-}
-
-@keyframes spin {
- 0% { transform: rotate(0deg); }
- 100% { transform: rotate(360deg); }
-}
-
-/* Error Messages */
-.error-message {
- background-color: #f8d7da;
- color: #721c24;
- padding: 15px;
- border-radius: 4px;
- margin: 20px 0;
- border: 1px solid #f5c6cb;
-}
-
-/* Representatives Section */
-#representatives-section {
- margin-top: 40px;
-}
-
-.rep-category {
- margin-bottom: 40px;
- background: white;
- padding: 20px;
- border-radius: 8px;
- box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
-}
-
-.rep-category h3 {
- color: #005a9c;
- margin-bottom: 20px;
- padding-bottom: 10px;
- border-bottom: 2px solid #e9ecef;
-}
-
-.rep-cards {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
- gap: 20px;
-}
-
-.rep-card {
- border: 1px solid #ddd;
- border-radius: 8px;
- padding: 20px;
- background: #f8f9fa;
- transition: transform 0.2s, box-shadow 0.2s;
-}
-
-.rep-card:hover {
- transform: translateY(-2px);
- box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
-}
-
-.rep-card h4 {
- color: #005a9c;
- margin-bottom: 10px;
-}
-
-.rep-card .rep-info {
- margin-bottom: 10px;
- color: #666;
-}
-
-.rep-card .rep-info strong {
- color: #333;
-}
-
-.rep-card .contact-info {
- margin-top: 15px;
- padding-top: 15px;
- border-top: 1px solid #ddd;
-}
-
-.rep-card .no-email {
- color: #6c757d;
- font-style: italic;
-}
-
-/* Modal */
-.modal {
- position: fixed;
- z-index: 1000;
- left: 0;
- top: 0;
- width: 100%;
- height: 100%;
- background-color: rgba(0, 0, 0, 0.5);
- display: flex;
- align-items: center;
- justify-content: center;
-}
-
-.modal-content {
- background-color: white;
- padding: 30px;
- border-radius: 8px;
- max-width: 600px;
- width: 90%;
- max-height: 90vh;
- overflow-y: auto;
- position: relative;
-}
-
-.close {
- position: absolute;
- right: 20px;
- top: 20px;
- font-size: 28px;
- font-weight: bold;
- cursor: pointer;
- color: #aaa;
-}
-
-.close:hover {
- color: #000;
-}
-
-.modal h2 {
- color: #005a9c;
- margin-bottom: 20px;
-}
-
-.form-actions {
- display: flex;
- gap: 10px;
- justify-content: flex-end;
- margin-top: 20px;
-}
-
-.char-count {
- display: block;
- text-align: right;
- color: #6c757d;
- font-size: 14px;
- margin-top: 5px;
-}
-
-.status-message {
- padding: 15px;
- border-radius: 4px;
- margin-top: 20px;
-}
-
-.status-message.success {
- background-color: #d4edda;
- color: #155724;
- border: 1px solid #c3e6cb;
-}
-
-.status-message.error {
- background-color: #f8d7da;
- color: #721c24;
- border: 1px solid #f5c6cb;
-}
-
-/* Utility Classes */
-.hidden {
- display: none !important;
-}
-
-.help-text {
- color: #6c757d;
- font-size: 14px;
- margin-top: 5px;
-}
-
-/* Mobile Responsiveness */
-@media (max-width: 768px) {
- .container {
- padding: 10px;
- }
-
- #postal-form .form-group {
- flex-direction: column;
- }
-
- .rep-cards {
- grid-template-columns: 1fr;
- }
-
- .modal-content {
- padding: 20px;
- width: 95%;
- }
-
- .form-actions {
- flex-direction: column;
- }
-
- .form-actions .btn {
- width: 100%;
- }
-}
\ No newline at end of file
diff --git a/influence/instruct.md b/influence/instruct.md
index 87502db..e374ded 100644
--- a/influence/instruct.md
+++ b/influence/instruct.md
@@ -14,7 +14,81 @@ Wej are using NocoDB as a no-code database solution. You will need to set up a N
- **Purpose:** Create influence campaigns by identifying and engaging with key community figures over email, text, or phone.
- **Backend:** Node.js/Express, with NocoDB as the database (REST API).
- **Frontend:** Vanilla JS, Leaflet.js for mapping, modular code in `/public/js`.
-- **Admin Panel:** Accessible via `/admin.html` for managing start location, walk sheet, cuts, and settings.
+- **Admin Panel:** Accessible via `/admin.html` for managing campaigns, users, and settings.
+
+## Campaign Settings Overview
+
+The application supports flexible campaign configuration through the admin panel:
+
+### Available Campaign Settings
+
+1. **Show Email Count** (`show_email_count`) - **Default: ON** ✅
+ - Displays total emails sent on campaign landing pages
+ - Provides social proof and engagement metrics
+ - Toggle via checkbox: "📊 Show Email Count" in admin panel
+ - **Database**: Boolean field in campaigns table
+ - **Backend**: Conditionally fetches count via `getCampaignEmailCount()`
+ - **Frontend**: Shows/hides stats banner in `campaign.js`
+
+2. **Allow SMTP Email** (`allow_smtp_email`) - **Default: ON** ✅
+ - Enables server-side email sending through configured SMTP
+ - Full logging and tracking of email delivery
+
+3. **Allow Mailto Link** (`allow_mailto_link`) - **Default: ON** ✅
+ - Enables browser-based email client launching
+ - Useful fallback for users without SMTP
+
+4. **Collect User Info** (`collect_user_info`) - **Default: ON** ✅
+ - Requests user name and email before participation
+ - Enables campaign tracking and follow-up
+
+5. **Allow Email Editing** (`allow_email_editing`) - **Default: OFF** ❌
+ - Lets users customize email templates before sending
+ - Increases personalization but may dilute messaging
+
+6. **Cover Photo** (`cover_photo`) - **Optional**
+ - Hero image for campaign landing pages
+ - Max 5MB, JPEG/PNG/GIF/WebP formats
+
+7. **Target Government Levels** (`target_government_levels`) - **MultiSelect**
+ - Federal, Provincial, Municipal, School Board
+ - Filters which representatives are shown
+
+8. **Campaign Status** (`status`) - **Required**
+ - Draft: Testing only, hidden from public
+ - Active: Visible on main page
+ - Paused: Temporarily disabled
+ - Archived: Completed campaigns
+
+### Using Campaign Settings in Code
+
+**When creating new campaign features:**
+- Add field to `build-nocodb.sh` campaigns table schema
+- Add field mapping in `nocodb.js` service (`createCampaign`, `updateCampaign`)
+- Add field normalization in `campaigns.js` controller
+- Add checkbox/input in `admin.html` create and edit forms
+- Add form handling in `admin.js` (read/write form data)
+- Implement frontend logic in `campaign.js` based on setting value
+- Update `README.MD` and `files-explainer.md` with new setting documentation
+
+**Example: Accessing settings in frontend:**
+```javascript
+// In campaign.js after loading campaign data
+if (this.campaign.show_email_count && this.campaign.emailCount !== null) {
+ document.getElementById('email-count').textContent = this.campaign.emailCount;
+ document.getElementById('campaign-stats').style.display = 'block';
+}
+```
+
+**Example: Setting default values in backend:**
+```javascript
+// In campaigns.js createCampaign()
+const campaignData = {
+ show_email_count: req.body.show_email_count ?? true, // Default ON
+ allow_email_editing: req.body.allow_email_editing ?? false, // Default OFF
+ // ... other fields
+};
+```
## Key Principles
diff --git a/influence/scripts/build-nocodb.sh b/influence/scripts/build-nocodb.sh
index 9c8268c..f1d42da 100755
--- a/influence/scripts/build-nocodb.sh
+++ b/influence/scripts/build-nocodb.sh
@@ -1053,6 +1053,12 @@ create_campaigns_table() {
"uidt": "Checkbox",
"cdf": "true"
},
+ {
+ "column_name": "show_call_count",
+ "title": "Show Call Count",
+ "uidt": "Checkbox",
+ "cdf": "true"
+ },
{
"column_name": "allow_email_editing",
"title": "Allow Email Editing",
@@ -1217,6 +1223,91 @@ create_campaign_emails_table() {
create_table "$base_id" "influence_campaign_emails" "$table_data" "Campaign email tracking"
}
+# Function to create the call logs table
+create_call_logs_table() {
+ local base_id=$1
+
+ local table_data='{
+ "table_name": "influence_call_logs",
+ "title": "Influence Call Logs",
+ "columns": [
+ {
+ "column_name": "id",
+ "title": "ID",
+ "uidt": "ID"
+ },
+ {
+ "column_name": "representative_name",
+ "title": "Representative Name",
+ "uidt": "SingleLineText",
+ "rqd": true
+ },
+ {
+ "column_name": "representative_title",
+ "title": "Representative Title",
+ "uidt": "SingleLineText",
+ "rqd": false
+ },
+ {
+ "column_name": "phone_number",
+ "title": "Phone Number",
+ "uidt": "SingleLineText",
+ "rqd": true
+ },
+ {
+ "column_name": "office_type",
+ "title": "Office Type",
+ "uidt": "SingleLineText",
+ "rqd": false
+ },
+ {
+ "column_name": "caller_name",
+ "title": "Caller Name",
+ "uidt": "SingleLineText",
+ "rqd": false
+ },
+ {
+ "column_name": "caller_email",
+ "title": "Caller Email",
+ "uidt": "Email",
+ "rqd": false
+ },
+ {
+ "column_name": "postal_code",
+ "title": "Postal Code",
+ "uidt": "SingleLineText",
+ "rqd": false
+ },
+ {
+ "column_name": "campaign_id",
+ "title": "Campaign ID",
+ "uidt": "SingleLineText",
+ "rqd": false
+ },
+ {
+ "column_name": "campaign_slug",
+ "title": "Campaign Slug",
+ "uidt": "SingleLineText",
+ "rqd": false
+ },
+ {
+ "column_name": "caller_ip",
+ "title": "Caller IP",
+ "uidt": "SingleLineText",
+ "rqd": false
+ },
+ {
+ "column_name": "called_at",
+ "title": "Called At",
+ "uidt": "DateTime",
+ "rqd": false
+ }
+ ]
+ }'
+
+ create_table "$base_id" "influence_call_logs" "$table_data" "Phone call tracking logs"
+}
+
# Function to create the users table
create_users_table() {
local base_id="$1"
@@ -1337,6 +1428,7 @@ update_env_with_table_ids() {
local campaigns_table_id=$5
local campaign_emails_table_id=$6
local users_table_id=$7
+ local call_logs_table_id=$8
print_status "Updating .env file with NocoDB project and table IDs..."
@@ -1371,6 +1463,7 @@ update_env_with_table_ids() {
update_env_var "NOCODB_TABLE_CAMPAIGNS" "$campaigns_table_id"
update_env_var "NOCODB_TABLE_CAMPAIGN_EMAILS" "$campaign_emails_table_id"
update_env_var "NOCODB_TABLE_USERS" "$users_table_id"
+ update_env_var "NOCODB_TABLE_CALLS" "$call_logs_table_id"
print_success "Successfully updated .env file with all table IDs"
@@ -1384,6 +1477,7 @@ update_env_with_table_ids() {
print_status "NOCODB_TABLE_CAMPAIGNS=$campaigns_table_id"
print_status "NOCODB_TABLE_CAMPAIGN_EMAILS=$campaign_emails_table_id"
print_status "NOCODB_TABLE_USERS=$users_table_id"
+ print_status "NOCODB_TABLE_CALLS=$call_logs_table_id"
}
@@ -1484,8 +1578,15 @@ main() {
exit 1
fi
+ # Create call logs table
+ CALL_LOGS_TABLE_ID=$(create_call_logs_table "$BASE_ID")
+ if [[ $? -ne 0 ]]; then
+ print_error "Failed to create call logs table"
+ exit 1
+ fi
+
# Validate all table IDs were created successfully
- if ! validate_table_ids "$REPRESENTATIVES_TABLE_ID" "$EMAIL_LOGS_TABLE_ID" "$POSTAL_CODES_TABLE_ID" "$CAMPAIGNS_TABLE_ID" "$CAMPAIGN_EMAILS_TABLE_ID" "$USERS_TABLE_ID"; then
+ if ! validate_table_ids "$REPRESENTATIVES_TABLE_ID" "$EMAIL_LOGS_TABLE_ID" "$POSTAL_CODES_TABLE_ID" "$CAMPAIGNS_TABLE_ID" "$CAMPAIGN_EMAILS_TABLE_ID" "$USERS_TABLE_ID" "$CALL_LOGS_TABLE_ID"; then
print_error "One or more table IDs are invalid"
exit 1
fi
@@ -1506,6 +1607,7 @@ main() {
table_mapping["influence_campaigns"]="$CAMPAIGNS_TABLE_ID"
table_mapping["influence_campaign_emails"]="$CAMPAIGN_EMAILS_TABLE_ID"
table_mapping["influence_users"]="$USERS_TABLE_ID"
+ table_mapping["influence_call_logs"]="$CALL_LOGS_TABLE_ID"
# Get source table information
local source_tables_response
@@ -1557,6 +1659,7 @@ main() {
print_status " - influence_campaigns (ID: $CAMPAIGNS_TABLE_ID)"
print_status " - influence_campaign_emails (ID: $CAMPAIGN_EMAILS_TABLE_ID)"
print_status " - influence_users (ID: $USERS_TABLE_ID)"
+ print_status " - influence_call_logs (ID: $CALL_LOGS_TABLE_ID)"
# Automatically update .env file with new project ID
print_status ""
@@ -1579,7 +1682,7 @@ main() {
fi
# Update .env file with table IDs
- update_env_with_table_ids "$BASE_ID" "$REPRESENTATIVES_TABLE_ID" "$EMAIL_LOGS_TABLE_ID" "$POSTAL_CODES_TABLE_ID" "$CAMPAIGNS_TABLE_ID" "$CAMPAIGN_EMAILS_TABLE_ID" "$USERS_TABLE_ID"
+ update_env_with_table_ids "$BASE_ID" "$REPRESENTATIVES_TABLE_ID" "$EMAIL_LOGS_TABLE_ID" "$POSTAL_CODES_TABLE_ID" "$CAMPAIGNS_TABLE_ID" "$CAMPAIGN_EMAILS_TABLE_ID" "$USERS_TABLE_ID" "$CALL_LOGS_TABLE_ID"
print_status ""
print_status "============================================================"
diff --git a/mkdocs/.cache/plugin/social/4fec9fd5349ccccf8012393802a1a5bd.png b/mkdocs/.cache/plugin/social/4fec9fd5349ccccf8012393802a1a5bd.png
new file mode 100644
index 0000000..c864a55
Binary files /dev/null and b/mkdocs/.cache/plugin/social/4fec9fd5349ccccf8012393802a1a5bd.png differ
diff --git a/mkdocs/.cache/plugin/social/bededf2c367a86f373a8685ce19f8e12.png b/mkdocs/.cache/plugin/social/bededf2c367a86f373a8685ce19f8e12.png
new file mode 100644
index 0000000..231ed01
Binary files /dev/null and b/mkdocs/.cache/plugin/social/bededf2c367a86f373a8685ce19f8e12.png differ
diff --git a/mkdocs/docs/assets/repo-data/admin-changemaker.lite.json b/mkdocs/docs/assets/repo-data/admin-changemaker.lite.json
index 0a51f87..91eceea 100644
--- a/mkdocs/docs/assets/repo-data/admin-changemaker.lite.json
+++ b/mkdocs/docs/assets/repo-data/admin-changemaker.lite.json
@@ -6,11 +6,11 @@
"language": "HTML",
"stars_count": 0,
"forks_count": 0,
- "open_issues_count": 18,
- "updated_at": "2025-09-11T13:42:21-06:00",
+ "open_issues_count": 22,
+ "updated_at": "2025-10-01T12:21:14-06:00",
"created_at": "2025-05-28T14:54:59-06:00",
"clone_url": "https://gitea.bnkops.com/admin/changemaker.lite.git",
"ssh_url": "git@gitea.bnkops.com:admin/changemaker.lite.git",
"default_branch": "main",
- "last_build_update": "2025-09-11T13:42:21-06:00"
+ "last_build_update": "2025-10-01T12:21:14-06:00"
}
\ No newline at end of file
diff --git a/mkdocs/docs/build/influence.md b/mkdocs/docs/build/influence.md
new file mode 100644
index 0000000..3e931ab
--- /dev/null
+++ b/mkdocs/docs/build/influence.md
@@ -0,0 +1,352 @@
+# Influence Build Guide
+
+Influence is BNKops campaign tool for connecting Alberta residents with their elected representatives across all levels of government.
+
+!!! info "Complete Configuration"
+ For detailed configuration, usage instructions, and troubleshooting, see the main [Influence README](https://gitea.bnkops.com/admin/changemaker.lite/src/branch/main/influence/README.MD).
+
+!!! tip "Email Testing"
+ The application includes MailHog integration for safe email testing during development. All test emails are caught locally and never sent to actual representatives.
+
+## Prerequisites
+
+- Docker and Docker Compose installed
+- NocoDB instance with API access
+- SMTP email configuration (or use MailHog for testing)
+- Domain name (optional but recommended for production)
+
+## Quick Build Process
+
+### 1. Get NocoDB API Token
+
+1. Login to your NocoDB instance
+2. Click user icon → **Account Settings** → **API Tokens**
+3. Create new token with read/write permissions
+4. Copy the token for the next step
+
+### 2. Configure Environment
+
+Navigate to the influence directory and create your environment file:
+
+```bash
+cd influence
+cp example.env .env
+```
+
+Edit the `.env` file with your configuration:
+
+```env
+# Server Configuration
+NODE_ENV=production
+PORT=3333
+
+# NocoDB Configuration
+NOCODB_API_URL=https://your-nocodb-instance.com
+NOCODB_API_TOKEN=your_nocodb_api_token_here
+NOCODB_PROJECT_ID=your_project_id_here
+
+# Email Configuration (Production SMTP)
+SMTP_HOST=smtp.gmail.com
+SMTP_PORT=587
+SMTP_SECURE=false
+SMTP_USER=your_email@gmail.com
+SMTP_PASS=your_app_password
+SMTP_FROM_NAME=BNKops Influence Campaign
+SMTP_FROM_EMAIL=your_email@gmail.com
+
+# Rate Limiting
+RATE_LIMIT_WINDOW_MS=900000
+RATE_LIMIT_MAX_REQUESTS=100
+```
+
+#### Development Mode Configuration
+
+For development and testing, use MailHog to catch emails:
+
+```env
+# Development Mode
+NODE_ENV=development
+EMAIL_TEST_MODE=true
+
+# MailHog SMTP (for development)
+SMTP_HOST=mailhog
+SMTP_PORT=1025
+SMTP_SECURE=false
+SMTP_USER=test
+SMTP_PASS=test
+SMTP_FROM_EMAIL=dev@albertainfluence.local
+SMTP_FROM_NAME="BNKops Influence Campaign (DEV)"
+
+# Email Testing
+TEST_EMAIL_RECIPIENT=developer@example.com
+```
+
+### 3. Auto-Create Database Structure
+
+Run the build script to create required NocoDB tables:
+
+```bash
+chmod +x scripts/build-nocodb.sh
+./scripts/build-nocodb.sh
+```
+
+This creates six tables:
+- **Campaigns** - Campaign configurations with email templates and settings
+- **Campaign Emails** - Tracking of all emails sent through campaigns
+- **Representatives** - Cached representative data by postal code
+- **Email Logs** - System-wide email delivery logs
+- **Postal Codes** - Canadian postal code geolocation data
+- **Users** - Admin authentication and access control
+
+### 4. Build and Deploy
+
+Build the Docker image and start the application:
+
+```bash
+# Build the Docker image
+docker compose build
+
+# Start the application (includes MailHog in development)
+docker compose up -d
+```
+
+## Verify Installation
+
+1. Check container status:
+ ```bash
+ docker compose ps
+ ```
+
+2. View logs:
+ ```bash
+ docker compose logs -f app
+ ```
+
+3. Access the application:
+ - **Main App**: http://localhost:3333
+ - **Admin Panel**: http://localhost:3333/admin.html
+ - **Email Testing** (dev): http://localhost:3333/email-test.html
+ - **MailHog UI** (dev): http://localhost:8025
+
+## Initial Setup
+
+### 1. Create Admin User
+
+Access the admin panel at `/admin.html` and create your first administrator account.
+
+### 2. Create Your First Campaign
+
+1. Login to the admin panel
+2. Click **"Create Campaign"**
+3. Configure basic settings:
+ - Campaign title and description
+ - Email subject and body template
+ - Upload cover photo (optional)
+4. Set campaign options:
+ - ✅ Allow SMTP Email - Enable server-side sending
+ - ✅ Allow Mailto Link - Enable browser-based mailto
+ - ✅ Collect User Info - Request name and email
+ - ✅ Show Email Count - Display engagement metrics
+ - ✅ Allow Email Editing - Let users customize message
+5. Select target government levels (Federal, Provincial, Municipal, School Board)
+6. Set status to **Active** to make campaign public
+7. Click **"Create Campaign"**
+
+### 3. Test Representative Lookup
+
+1. Visit the homepage
+2. Enter an Alberta postal code (e.g., T5N4B8)
+3. View representatives at all government levels
+4. Test email sending functionality
+
+## Development Workflow
+
+### Email Testing Interface
+
+Access the email testing interface at `/email-test.html` (requires admin login):
+
+**Features:**
+- 📧 **Quick Test** - Send test email with one click
+- 👁️ **Email Preview** - Preview email formatting before sending
+- ✏️ **Custom Composition** - Test with custom subject and message
+- 📊 **Email Logs** - View all sent emails with filtering
+- 🔧 **SMTP Diagnostics** - Test connection and troubleshoot
+
+### MailHog Web Interface
+
+Access MailHog at http://localhost:8025 to:
+- View all caught emails during development
+- Inspect email content, headers, and formatting
+- Search and filter test emails
+- Verify emails never leave your local environment
+
+### Switching to Production
+
+When ready to deploy to production:
+
+1. Update `.env` with production SMTP settings:
+ ```env
+ EMAIL_TEST_MODE=false
+ NODE_ENV=production
+ SMTP_HOST=smtp.your-provider.com
+ SMTP_USER=your-real-email@domain.com
+ SMTP_PASS=your-real-password
+ ```
+
+2. Restart the application:
+ ```bash
+ docker compose restart
+ ```
+
+## Key Features
+
+### Representative Lookup
+- Search by Alberta postal code (T prefix)
+- Display federal MPs, provincial MLAs, municipal representatives
+- Smart caching with NocoDB for fast performance
+- Graceful fallback to Represent API when cache unavailable
+
+### Campaign System
+- Create unlimited advocacy campaigns
+- Upload cover photos for campaign pages
+- Customizable email templates
+- Optional user information collection
+- Toggle email count display for engagement metrics
+- Multi-level government targeting
+
+### Email Integration
+- SMTP email sending with delivery confirmation
+- Mailto link support for browser-based email
+- Comprehensive email logging
+- Rate limiting for API protection
+- Test mode for safe development
+
+## API Endpoints
+
+### Public Endpoints
+- `GET /` - Homepage with representative lookup
+- `GET /campaign/:slug` - Individual campaign page
+- `GET /api/public/campaigns` - List active campaigns
+- `GET /api/representatives/by-postal/:postalCode` - Find representatives
+- `POST /api/emails/send` - Send campaign email
+
+### Admin Endpoints (Authentication Required)
+- `GET /admin.html` - Campaign management dashboard
+- `GET /email-test.html` - Email testing interface
+- `POST /api/emails/preview` - Preview email without sending
+- `POST /api/emails/test` - Send test email
+- `GET /api/test-smtp` - Test SMTP connection
+
+## Maintenance Commands
+
+### Update Application
+```bash
+docker compose down
+git pull origin main
+docker compose build
+docker compose up -d
+```
+
+### Development Mode
+```bash
+cd app
+npm install
+npm run dev
+```
+
+### View Logs
+```bash
+# Follow application logs
+docker compose logs -f app
+
+# View MailHog logs (development)
+docker compose logs -f mailhog
+```
+
+### Database Backup
+```bash
+# Backup is handled through NocoDB
+# Access NocoDB admin panel to export tables
+```
+
+### Health Check
+```bash
+curl http://localhost:3333/api/health
+```
+
+## Troubleshooting
+
+### NocoDB Connection Issues
+- Verify `NOCODB_API_URL` and `NOCODB_API_TOKEN` in `.env`
+- Run `./scripts/build-nocodb.sh` to ensure tables exist
+- Application works without NocoDB (API fallback mode)
+
+### Email Not Sending
+- In development: Check MailHog UI at http://localhost:8025
+- Verify SMTP credentials in `.env`
+- Use `/email-test.html` interface for diagnostics
+- Check email logs via admin panel
+- Review `docker compose logs -f app` for errors
+
+### No Representatives Found
+- Ensure postal code starts with 'T' (Alberta only)
+- Try different postal code format (remove spaces)
+- Check Represent API status: `curl http://localhost:3333/api/test-represent`
+- Review application logs for API errors
+
+### Campaign Not Appearing
+- Verify campaign status is set to "Active"
+- Check campaign configuration in admin panel
+- Clear browser cache and reload homepage
+- Review console for JavaScript errors
+
+## Production Deployment
+
+### Environment Configuration
+```env
+NODE_ENV=production
+EMAIL_TEST_MODE=false
+PORT=3333
+
+# Use production SMTP settings
+SMTP_HOST=smtp.your-provider.com
+SMTP_PORT=587
+SMTP_SECURE=false
+SMTP_USER=your-production-email@domain.com
+SMTP_PASS=your-production-password
+```
+
+### Docker Production
+```bash
+# Build and start in production mode
+docker compose -f docker-compose.yml up -d --build
+
+# View logs
+docker compose logs -f app
+
+# Monitor health
+watch curl http://localhost:3333/api/health
+```
+
+### Monitoring
+- Health check endpoint: `/api/health`
+- Email logs via admin panel
+- NocoDB integration status in logs
+- Rate limiting metrics in application logs
+
+## Security Considerations
+
+- 🔒 Always use strong passwords for admin accounts
+- 🔒 Enable HTTPS in production (use reverse proxy)
+- 🔒 Rotate SMTP credentials regularly
+- 🔒 Monitor email logs for suspicious activity
+- 🔒 Set appropriate rate limits based on expected traffic
+- 🔒 Keep NocoDB API tokens secure and rotate periodically
+- 🔒 Use `EMAIL_TEST_MODE=false` only in production
+
+## Support
+
+For detailed configuration, troubleshooting, and usage instructions, see:
+- [Main Influence README](https://gitea.bnkops.com/admin/changemaker.lite/src/branch/main/influence/README.MD)
+- [Campaign Settings Guide](https://gitea.bnkops.com/admin/changemaker.lite/src/branch/main/influence/CAMPAIGN_SETTINGS_GUIDE.md)
+- [Files Explainer](https://gitea.bnkops.com/admin/changemaker.lite/src/branch/main/influence/files-explainer.md)
diff --git a/mkdocs/docs/overrides/lander.html b/mkdocs/docs/overrides/lander.html
index d9f96f7..ef0e09e 100644
--- a/mkdocs/docs/overrides/lander.html
+++ b/mkdocs/docs/overrides/lander.html
@@ -1289,11 +1289,13 @@
+
@@ -1504,6 +1506,24 @@
+