feat(blog): add detailed update on Influence and Map app developments since August

A bunch of udpates to the listmonk sync to add influence to it
This commit is contained in:
admin 2025-10-25 12:45:35 -06:00
parent e5c32ad25a
commit 4d8b9effd0
26 changed files with 4125 additions and 156 deletions

View File

@ -0,0 +1,161 @@
# CSRF Security Update - Fix Summary
## Date: October 23, 2025
## Issues Encountered
After implementing CSRF security updates, the application experienced two main issues:
### 1. Login Failed with "Invalid CSRF token"
**Problem**: The login endpoint required a CSRF token, but users couldn't get a token before logging in (chicken-and-egg problem).
**Root Cause**: The `/api/auth/login` endpoint was being protected by CSRF middleware, but there's no session yet during initial login.
**Solution**: Added `/api/auth/login` and `/api/auth/session` to the CSRF exempt routes list in `app/middleware/csrf.js`. Login endpoints use credentials (username/password) for authentication, so they don't need CSRF protection.
### 2. Campaign Creation Failed with Infinite Retry Loop
**Problem**: When creating campaigns, the app would get stuck in an infinite retry loop with repeated "CSRF token validation failed" errors.
**Root Causes**:
1. The API client (`api-client.js`) wasn't fetching or sending CSRF tokens at all
2. The retry logic didn't have a guard against infinite recursion
3. FormData wasn't including the CSRF token
**Solutions**:
1. **Added CSRF token management** to the API client:
- `fetchCsrfToken()` - Fetches token from `/api/csrf-token` endpoint
- `ensureCsrfToken()` - Ensures a valid token exists before requests
- Tokens are automatically included in state-changing requests (POST, PUT, PATCH, DELETE)
2. **Fixed infinite retry loop**:
- Added `isRetry` parameter to `makeRequest()`, `postFormData()`, and `putFormData()`
- Retry only happens once per request
- If second attempt fails, error is thrown to the user
3. **Enhanced token handling**:
- JSON requests: Token sent via `X-CSRF-Token` header
- FormData requests: Token sent via `_csrf` field
- Token automatically refreshed if server responds with new token
4. **Server-side updates**:
- Added explicit CSRF protection to `/api/csrf-token` endpoint so it can generate tokens
- Exported `csrfProtection` middleware for explicit use
## Files Modified
### 1. `app/middleware/csrf.js`
```javascript
// Added to exempt routes:
const csrfExemptRoutes = [
'/api/health',
'/api/metrics',
'/api/config',
'/api/auth/login', // ← NEW: Login uses credentials
'/api/auth/session', // ← NEW: Session check is read-only
'/api/representatives/postal/',
'/api/campaigns/public'
];
// Enhanced getCsrfToken with error handling
```
### 2. `app/server.js`
```javascript
// Added csrfProtection to imports
const { conditionalCsrfProtection, getCsrfToken, csrfProtection } = require('./middleware/csrf');
// Applied explicit CSRF protection to token endpoint
app.get('/api/csrf-token', csrfProtection, getCsrfToken);
```
### 3. `app/public/js/api-client.js`
- Added CSRF token caching and fetching logic
- Modified `makeRequest()` to include `X-CSRF-Token` header
- Modified `postFormData()` and `putFormData()` to include `_csrf` field
- Added retry logic with infinite loop protection (max 1 retry)
- Added automatic token refresh on 403 errors
## How CSRF Protection Works Now
### Flow for State-Changing Requests (POST, PUT, DELETE):
```
1. User Action (e.g., "Create Campaign")
2. API Client checks if CSRF token exists
↓ (if no token)
3. Fetch token from GET /api/csrf-token
4. Include token in request:
- Header: X-CSRF-Token (for JSON)
- FormData: _csrf (for file uploads)
5. Server validates token matches session
6a. Success → Process request
6b. Invalid Token → Return 403
↓ (on 403, if not a retry)
7. Clear token, fetch new one, retry ONCE
8a. Success → Return data
8b. Still fails → Throw error to user
```
### Protected vs Exempt Endpoints
**Protected (requires CSRF token)**:
- ✅ POST `/api/admin/campaigns` - Create campaign
- ✅ PUT `/api/admin/campaigns/:id` - Update campaign
- ✅ POST `/api/emails/send` - Send email
- ✅ POST `/api/auth/logout` - Logout
- ✅ POST `/api/auth/change-password` - Change password
**Exempt (no CSRF required)**:
- ✅ GET (all GET requests are safe)
- ✅ POST `/api/auth/login` - Uses credentials
- ✅ GET `/api/auth/session` - Read-only check
- ✅ GET `/api/health` - Public health check
- ✅ GET `/api/metrics` - Prometheus metrics
## Testing Checklist
- [x] Login as admin works
- [ ] Create new campaign works
- [ ] Update existing campaign works
- [ ] Delete campaign works
- [ ] Send email to representative works
- [ ] Logout works
- [ ] Password change works
- [ ] Public pages work without authentication
## Security Benefits
1. **CSRF Attack Prevention**: Malicious sites can't forge requests to your app
2. **Session Hijacking Protection**: httpOnly, secure, sameSite cookies
3. **Defense in Depth**: Multiple security layers (Helmet, rate limiting, CSRF, validation)
4. **Automatic Token Rotation**: Tokens refresh on each response when available
5. **Retry Logic**: Handles token expiration gracefully
## Important Notes
- CSRF tokens are tied to sessions and expire with the session (1 hour)
- Tokens are stored in cookies (httpOnly, secure in production)
- The retry logic prevents infinite loops by limiting to 1 retry per request
- Login doesn't need CSRF because it uses credentials for authentication
- All state-changing operations (POST/PUT/DELETE) now require valid CSRF tokens
## Troubleshooting
**If you see "Invalid CSRF token" errors:**
1. Check browser console for detailed error messages
2. Clear browser cookies and session storage
3. Logout and login again to get a fresh session
4. Verify the session hasn't expired (1 hour timeout)
5. Check server logs for CSRF validation failures
**If infinite retry loop occurs:**
1. Check that `isRetry` parameter is being passed correctly
2. Verify FormData isn't being reused across retries
3. Clear the API client's cached token: `window.apiClient.csrfToken = null`

View File

@ -0,0 +1,243 @@
# Custom Recipients Implementation
## Overview
This feature allows campaigns to target any email address (custom recipients) instead of or in addition to elected representatives from the Represent API.
## Implementation Summary
### ✅ Backend (Complete)
#### 1. Database Schema (`scripts/build-nocodb.sh`)
- **custom_recipients table** with fields:
- `id` - Primary key
- `campaign_id` - Links to campaigns table
- `campaign_slug` - Campaign identifier
- `recipient_name` - Full name of recipient
- `recipient_email` - Email address
- `recipient_title` - Job title/position (optional)
- `recipient_organization` - Organization name (optional)
- `notes` - Internal notes (optional)
- `is_active` - Boolean flag
- **campaigns table** updated:
- Added `allow_custom_recipients` boolean field (default: false)
#### 2. Backend Controller (`app/controllers/customRecipients.js`)
Full CRUD operations:
- `getRecipientsByCampaign(req, res)` - Fetch all recipients for a campaign
- `createRecipient(req, res)` - Add single recipient with validation
- `bulkCreateRecipients(req, res)` - Import multiple recipients from CSV
- `updateRecipient(req, res)` - Update recipient details
- `deleteRecipient(req, res)` - Delete single recipient
- `deleteAllRecipients(req, res)` - Clear all recipients for a campaign
#### 3. NocoDB Service (`app/services/nocodb.js`)
- `getCustomRecipients(campaignId)` - Query by campaign ID
- `getCustomRecipientsBySlug(campaignSlug)` - Query by slug
- `createCustomRecipient(recipientData)` - Create with field mapping
- `updateCustomRecipient(recipientId, updateData)` - Partial updates
- `deleteCustomRecipient(recipientId)` - Single deletion
- `deleteCustomRecipientsByCampaign(campaignId)` - Bulk deletion
#### 4. API Routes (`app/routes/api.js`)
All routes protected with `requireNonTemp` authentication:
- `GET /api/campaigns/:slug/custom-recipients` - List all recipients
- `POST /api/campaigns/:slug/custom-recipients` - Create single recipient
- `POST /api/campaigns/:slug/custom-recipients/bulk` - Bulk import
- `PUT /api/campaigns/:slug/custom-recipients/:id` - Update recipient
- `DELETE /api/campaigns/:slug/custom-recipients/:id` - Delete recipient
- `DELETE /api/campaigns/:slug/custom-recipients` - Delete all recipients
#### 5. Campaign Controller Updates (`app/controllers/campaigns.js`)
- Added `allow_custom_recipients` field to all campaign CRUD operations
- Field normalization in 5+ locations for consistent API responses
### ✅ Frontend (Complete)
#### 1. JavaScript Module (`app/public/js/custom-recipients.js`)
Comprehensive module with:
- **CRUD Operations**: Add, edit, delete recipients
- **Bulk Import**: CSV file upload or paste with parsing
- **Validation**: Email format validation
- **UI Management**: Dynamic recipient list display with cards
- **Error Handling**: User-friendly error messages
- **XSS Protection**: HTML escaping for security
Key methods:
```javascript
CustomRecipients.init(campaignSlug) // Initialize module
CustomRecipients.loadRecipients(slug) // Load from API
CustomRecipients.displayRecipients() // Render list
// Plus handleAddRecipient, handleEditRecipient, handleDeleteRecipient, etc.
```
#### 2. Admin Panel Integration (`app/public/admin.html` + `app/public/js/admin.js`)
- **Create Form**: Checkbox to enable custom recipients
- **Edit Form**:
- Checkbox with show/hide toggle
- Add recipient form (5 fields: name, email, title, organization, notes)
- Bulk CSV import button with modal
- Recipients list with edit/delete actions
- Clear all button
- **JavaScript Integration**:
- `toggleCustomRecipientsSection()` - Show/hide based on checkbox
- `setupCustomRecipientsHandlers()` - Event listeners for checkbox
- Auto-load recipients when editing campaign with feature enabled
- Form data includes `allow_custom_recipients` in create/update
#### 3. Bulk Import Modal (`app/public/admin.html`)
Complete modal with:
- CSV format instructions
- File upload input
- Paste textarea for direct CSV input
- Import results display with success/failure details
- CSV format: `recipient_name,recipient_email,recipient_title,recipient_organization,notes`
#### 4. CSS Styling (`app/public/admin.html`)
- `.recipient-card` - Card layout with hover effects
- `.recipient-info` - Name, email, metadata display
- `.recipient-actions` - Edit/delete icon buttons with hover colors
- `.bulk-import-help` - Modal styling
- Responsive grid layout
## Usage
### For Administrators:
1. **Create Campaign**:
- Check "Allow Custom Recipients" during creation
- An info section will appear explaining that recipients can be added after campaign is created
- Complete the campaign creation
2. **Edit Campaign**:
- Navigate to the Edit tab and select your campaign
- Check "Allow Custom Recipients" to enable the feature
- The custom recipients management section will appear below the checkbox
3. **Add Single Recipient**:
- Fill in name (required) and email (required)
- Optionally add title, organization, notes
- Click "Add Recipient"
4. **Bulk Import**:
- Click "Bulk Import (CSV)" button
- Upload CSV file or paste CSV data
- CSV format: `recipient_name,recipient_email,recipient_title,recipient_organization,notes`
- First row can be header (will be skipped if contains "recipient_name")
- Results show success/failure for each row
5. **Edit Recipient**:
- Click edit icon on recipient card
- Form populates with current data
- Make changes and click "Update Recipient"
- Or click "Cancel" to revert
6. **Delete Recipients**:
- Single: Click delete icon on card
- All: Click "Clear All" button
### API Examples:
```bash
# Create recipient
curl -X POST /api/campaigns/my-campaign/custom-recipients \
-H "Content-Type: application/json" \
-d '{
"recipient_name": "Jane Doe",
"recipient_email": "jane@example.com",
"recipient_title": "CEO",
"recipient_organization": "Tech Corp"
}'
# Bulk import
curl -X POST /api/campaigns/my-campaign/custom-recipients/bulk \
-H "Content-Type: application/json" \
-d '{
"recipients": [
{"recipient_name": "John Smith", "recipient_email": "john@example.com"},
{"recipient_name": "Jane Doe", "recipient_email": "jane@example.com"}
]
}'
# Get all recipients
curl /api/campaigns/my-campaign/custom-recipients
# Update recipient
curl -X PUT /api/campaigns/my-campaign/custom-recipients/123 \
-H "Content-Type: application/json" \
-d '{"recipient_title": "CTO"}'
# Delete recipient
curl -X DELETE /api/campaigns/my-campaign/custom-recipients/123
# Delete all recipients
curl -X DELETE /api/campaigns/my-campaign/custom-recipients
```
## Security Features
- **Authentication**: All API routes require non-temporary user session
- **Validation**: Email format validation on client and server
- **XSS Protection**: HTML escaping in display
- **Campaign Check**: Verifies campaign exists and feature is enabled
- **Input Sanitization**: express-validator on API endpoints
## Next Steps (TODO)
1. **Dashboard Integration**: Add same UI to `dashboard.html` for regular users
2. **Campaign Display**: Update `campaign.js` to show custom recipients alongside elected officials
3. **Email Composer**: Ensure custom recipients work in email sending flow
4. **Testing**: Comprehensive end-to-end testing
5. **Documentation**: Update main README and files-explainer
## Files Modified/Created
### Backend:
- ✅ `scripts/build-nocodb.sh` - Database schema
- ✅ `app/controllers/customRecipients.js` - NEW FILE (282 lines)
- ✅ `app/services/nocodb.js` - Service methods
- ✅ `app/routes/api.js` - API endpoints
- ✅ `app/controllers/campaigns.js` - Field updates
### Frontend:
- ✅ `app/public/js/custom-recipients.js` - NEW FILE (538 lines)
- ✅ `app/public/js/admin.js` - Integration code
- ✅ `app/public/admin.html` - UI components and forms
### Documentation:
- ✅ `CUSTOM_RECIPIENTS_IMPLEMENTATION.md` - This file
## Testing Checklist
- [ ] Database table creation (run build-nocodb.sh)
- [ ] Create campaign with custom recipients enabled
- [ ] Add single recipient via form
- [ ] Edit recipient information
- [ ] Delete single recipient
- [ ] Bulk import via CSV file
- [ ] Bulk import via paste
- [ ] Clear all recipients
- [ ] Toggle checkbox on/off
- [ ] Verify API authentication
- [ ] Test with campaign where feature is disabled
- [ ] Check recipient display on campaign page
- [ ] Test email sending to custom recipients
## Known Limitations
1. Custom recipients can only be added AFTER campaign is created (not during creation)
2. Dashboard UI not yet implemented (admin panel only)
3. Campaign display page doesn't show custom recipients yet
4. CSV import uses simple comma splitting (doesn't handle quoted commas)
5. No duplicate email detection
## Future Enhancements
- [ ] Duplicate email detection/prevention
- [ ] Import validation preview before saving
- [ ] Export recipients to CSV
- [ ] Recipient groups/categories
- [ ] Import from external sources (Google Contacts, etc.)
- [ ] Recipient engagement tracking
- [ ] Custom fields for recipients
- [ ] Merge tags in email templates using recipient data

View File

@ -0,0 +1,119 @@
# Debugging Custom Recipients Feature
## Changes Made to Fix Checkbox Toggle
### Issue
The "Allow Custom Recipients" checkbox wasn't showing/hiding the custom recipients management section when clicked.
### Root Causes
1. **Event Listener Timing**: Original code tried to attach event listeners during `init()`, but the edit form elements didn't exist yet
2. **Not Following Best Practices**: Wasn't using event delegation pattern as required by `instruct.md`
### Solution
Switched to **event delegation** pattern using a single document-level listener:
```javascript
// OLD (didn't work - elements didn't exist yet):
const editCheckbox = document.getElementById('edit-allow-custom-recipients');
if (editCheckbox) {
editCheckbox.addEventListener('change', handler);
}
// NEW (works with event delegation):
document.addEventListener('change', (e) => {
if (e.target.id === 'edit-allow-custom-recipients') {
// Handle the change
}
});
```
### Benefits of Event Delegation
1. ✅ Works regardless of when elements are added to DOM
2. ✅ Follows `instruct.md` rules about using `addEventListener`
3. ✅ No need to reattach listeners when switching tabs
4. ✅ Single listener handles all checkbox changes efficiently
### Console Logs Added for Debugging
The following console logs were added to help trace execution:
1. **admin.js init()**: "AdminPanel init started" and "AdminPanel init completed"
2. **custom-recipients.js load**: "Custom Recipients module loading..." and "Custom Recipients module initialized"
3. **setupCustomRecipientsHandlers()**: "Setting up custom recipients handlers" and "Custom recipients handlers set up with event delegation"
4. **Checkbox change**: "Custom recipients checkbox changed: true/false"
5. **Module init**: "Initializing CustomRecipients module for campaign: [slug]"
6. **toggleCustomRecipientsSection()**: "Toggling custom recipients section: true/false" and "Section display set to: block/none"
### Testing Steps
1. **Open Browser Console** (F12)
2. **Navigate to Admin Panel** → Look for "AdminPanel init started"
3. **Look for Module Load** → "Custom Recipients module loading..."
**Test Create Form:**
4. **Switch to Create Tab** → Click "Create New Campaign"
5. **Check the Checkbox** → "Allow Custom Recipients"
6. **Verify Info Section Appears** → Should see: "Custom recipients can only be added after the campaign is created"
7. **Console Should Show**: "Create form: Custom recipients checkbox changed: true"
**Test Edit Form:**
8. **Switch to Edit Tab** → Select a campaign
9. **Check the Checkbox** → "Allow Custom Recipients"
10. **You Should See**:
- "Custom recipients checkbox changed: true"
- "Toggling custom recipients section: true"
- "Section display set to: block"
- "Initializing CustomRecipients module for campaign: [slug]"
11. **Verify Section Appears** → The "Manage Custom Recipients" section with forms should now be visible
### If It Still Doesn't Work
Check the following in browser console:
1. **Are scripts loading?**
```
Look for: "Custom Recipients module loading..."
If missing: Check network tab for 404 errors on custom-recipients.js
```
2. **Is event delegation working?**
```
Look for: "Custom recipients handlers set up with event delegation"
If missing: Check if setupCustomRecipientsHandlers() is being called
```
3. **Is checkbox being detected?**
```
Click checkbox and look for: "Custom recipients checkbox changed: true"
If missing: Check if checkbox ID is correct in HTML
```
4. **Is section element found?**
```
Look for: "section found: [object HTMLDivElement]"
If it says "section found: null": Check if section ID matches in HTML
```
5. **Manual test in console:**
```javascript
// Check if checkbox exists
document.getElementById('edit-allow-custom-recipients')
// Check if section exists
document.getElementById('edit-custom-recipients-section')
// Check if module loaded
window.CustomRecipients
// Manually toggle section
document.getElementById('edit-custom-recipients-section').style.display = 'block';
```
### Files Modified
- ✅ `app/public/js/admin.js` - Changed to event delegation pattern, added logging
- ✅ `app/public/js/custom-recipients.js` - Added loading logs
- ✅ No changes needed to HTML (already correct)
### Next Steps After Confirming It Works
1. Remove excessive console.log statements (or convert to debug mode)
2. Test full workflow: add recipient, edit, delete, bulk import
3. Proceed with dashboard.html integration

View File

@ -132,6 +132,13 @@ class CampaignsController {
console.log(`Call count result: ${callCount}`); console.log(`Call count result: ${callCount}`);
} }
// Get verified response count
let verifiedResponseCount = 0;
if (id != null) {
verifiedResponseCount = await nocoDB.getCampaignVerifiedResponseCount(id);
console.log(`Verified response count result: ${verifiedResponseCount}`);
}
const rawTargetLevels = campaign['Target Government Levels'] || campaign.target_government_levels; const rawTargetLevels = campaign['Target Government Levels'] || campaign.target_government_levels;
const normalizedTargetLevels = normalizeTargetLevels(rawTargetLevels); const normalizedTargetLevels = normalizeTargetLevels(rawTargetLevels);
@ -149,7 +156,8 @@ class CampaignsController {
target_government_levels: normalizedTargetLevels, target_government_levels: normalizedTargetLevels,
created_at: campaign.CreatedAt || campaign.created_at, created_at: campaign.CreatedAt || campaign.created_at,
emailCount, emailCount,
callCount callCount,
verifiedResponseCount
}; };
}) })
); );
@ -200,6 +208,7 @@ class CampaignsController {
collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info, collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info,
show_email_count: campaign['Show Email Count'] || campaign.show_email_count, show_email_count: campaign['Show Email Count'] || campaign.show_email_count,
allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing, allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing,
allow_custom_recipients: campaign['Allow Custom Recipients'] || campaign.allow_custom_recipients,
show_response_wall: campaign['Show Response Wall Button'] || campaign.show_response_wall, show_response_wall: campaign['Show Response Wall Button'] || campaign.show_response_wall,
target_government_levels: normalizedTargetLevels, target_government_levels: normalizedTargetLevels,
created_at: campaign.CreatedAt || campaign.created_at, created_at: campaign.CreatedAt || campaign.created_at,
@ -283,6 +292,7 @@ class CampaignsController {
collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info, collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info,
show_email_count: campaign['Show Email Count'] || campaign.show_email_count, show_email_count: campaign['Show Email Count'] || campaign.show_email_count,
allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing, allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing,
allow_custom_recipients: campaign['Allow Custom Recipients'] || campaign.allow_custom_recipients,
show_response_wall: campaign['Show Response Wall Button'] || campaign.show_response_wall, show_response_wall: campaign['Show Response Wall Button'] || campaign.show_response_wall,
target_government_levels: normalizeTargetLevels(campaign['Target Government Levels'] || campaign.target_government_levels), target_government_levels: normalizeTargetLevels(campaign['Target Government Levels'] || campaign.target_government_levels),
created_at: campaign.CreatedAt || campaign.created_at, created_at: campaign.CreatedAt || campaign.created_at,
@ -370,6 +380,7 @@ class CampaignsController {
show_email_count: campaign['Show Email Count'] || campaign.show_email_count, show_email_count: campaign['Show Email Count'] || campaign.show_email_count,
show_call_count: campaign['Show Call Count'] || campaign.show_call_count, show_call_count: campaign['Show Call Count'] || campaign.show_call_count,
allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing, allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing,
allow_custom_recipients: campaign['Allow Custom Recipients'] || campaign.allow_custom_recipients,
show_response_wall: campaign['Show Response Wall Button'] || campaign.show_response_wall, show_response_wall: campaign['Show Response Wall Button'] || campaign.show_response_wall,
target_government_levels: normalizeTargetLevels(campaign['Target Government Levels'] || campaign.target_government_levels), target_government_levels: normalizeTargetLevels(campaign['Target Government Levels'] || campaign.target_government_levels),
emailCount, emailCount,
@ -390,6 +401,22 @@ class CampaignsController {
// Create new campaign // Create new campaign
async createCampaign(req, res, next) { async createCampaign(req, res, next) {
try { try {
// Convert boolean fields from string to actual boolean if needed
const booleanFields = [
'allow_smtp_email',
'allow_mailto_link',
'collect_user_info',
'show_email_count',
'allow_email_editing',
'allow_custom_recipients'
];
booleanFields.forEach(field => {
if (req.body[field] !== undefined && typeof req.body[field] === 'string') {
req.body[field] = req.body[field] === 'true';
}
});
const { const {
title, title,
description, description,
@ -402,6 +429,7 @@ class CampaignsController {
collect_user_info = true, collect_user_info = true,
show_email_count = true, show_email_count = true,
allow_email_editing = false, allow_email_editing = false,
allow_custom_recipients = false,
target_government_levels = ['Federal', 'Provincial', 'Municipal'] target_government_levels = ['Federal', 'Provincial', 'Municipal']
} = req.body; } = req.body;
@ -432,6 +460,7 @@ class CampaignsController {
collect_user_info, collect_user_info,
show_email_count, show_email_count,
allow_email_editing, allow_email_editing,
allow_custom_recipients,
// NocoDB MultiSelect expects an array of values // NocoDB MultiSelect expects an array of values
target_government_levels: normalizeTargetLevels(target_government_levels), target_government_levels: normalizeTargetLevels(target_government_levels),
// Add user ownership data // Add user ownership data
@ -462,6 +491,7 @@ class CampaignsController {
collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info, collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info,
show_email_count: campaign['Show Email Count'] || campaign.show_email_count, show_email_count: campaign['Show Email Count'] || campaign.show_email_count,
allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing, allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing,
allow_custom_recipients: campaign['Allow Custom Recipients'] || campaign.allow_custom_recipients,
target_government_levels: normalizeTargetLevels(campaign['Target Government Levels'] || campaign.target_government_levels), target_government_levels: normalizeTargetLevels(campaign['Target Government Levels'] || campaign.target_government_levels),
created_at: campaign.CreatedAt || campaign.created_at, created_at: campaign.CreatedAt || campaign.created_at,
updated_at: campaign.UpdatedAt || campaign.updated_at, updated_at: campaign.UpdatedAt || campaign.updated_at,
@ -549,6 +579,27 @@ class CampaignsController {
console.log('No cover photo file in request'); console.log('No cover photo file in request');
} }
// Convert boolean fields from string to actual boolean
const booleanFields = [
'allow_smtp_email',
'allow_mailto_link',
'collect_user_info',
'show_email_count',
'allow_email_editing',
'show_response_wall',
'allow_custom_recipients'
];
booleanFields.forEach(field => {
if (updates[field] !== undefined) {
// Convert string 'true'/'false' to boolean
if (typeof updates[field] === 'string') {
updates[field] = updates[field] === 'true';
}
// Already boolean, leave as is
}
});
console.log('Updates object before saving:', updates); console.log('Updates object before saving:', updates);
if (updates.status !== undefined) { if (updates.status !== undefined) {
@ -604,6 +655,7 @@ class CampaignsController {
collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info, collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info,
show_email_count: campaign['Show Email Count'] || campaign.show_email_count, show_email_count: campaign['Show Email Count'] || campaign.show_email_count,
allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing, allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing,
allow_custom_recipients: campaign['Allow Custom Recipients'] || campaign.allow_custom_recipients,
target_government_levels: normalizeTargetLevels(campaign['Target Government Levels'] || campaign.target_government_levels), target_government_levels: normalizeTargetLevels(campaign['Target Government Levels'] || campaign.target_government_levels),
created_at: campaign.CreatedAt || campaign.created_at, created_at: campaign.CreatedAt || campaign.created_at,
updated_at: campaign.UpdatedAt || campaign.updated_at, updated_at: campaign.UpdatedAt || campaign.updated_at,
@ -936,9 +988,33 @@ class CampaignsController {
}); });
}); });
// Add custom recipients if enabled for this campaign
let customRecipients = [];
if (campaign['Allow Custom Recipients']) {
try {
customRecipients = await nocoDB.getCustomRecipientsBySlug(slug);
// Mark custom recipients with a type field to distinguish them
customRecipients = customRecipients.map(recipient => ({
...recipient,
is_custom_recipient: true,
name: recipient.recipient_name,
email: recipient.recipient_email,
elected_office: recipient.recipient_title || 'Custom Recipient',
party_name: recipient.recipient_organization || '',
photo_url: null // Custom recipients don't have photos
}));
} catch (error) {
console.error('Error loading custom recipients:', error);
// Don't fail the entire request if custom recipients fail to load
}
}
// Combine elected officials and custom recipients
const allRecipients = [...filteredRepresentatives, ...customRecipients];
res.json({ res.json({
success: true, success: true,
representatives: filteredRepresentatives, representatives: allRecipients,
location: { location: {
city: result?.city || cachedData[0]?.city || 'Alberta', city: result?.city || cachedData[0]?.city || 'Alberta',
province: result?.province || cachedData[0]?.province || 'AB' province: result?.province || cachedData[0]?.province || 'AB'

View File

@ -0,0 +1,283 @@
const nocoDB = require('../services/nocodb');
const { validateEmail } = require('../utils/validators');
class CustomRecipientsController {
/**
* Get all custom recipients for a campaign
*/
async getRecipientsByCampaign(req, res, next) {
try {
const { slug } = req.params;
// Get campaign first to verify it exists and get ID
const campaign = await nocoDB.getCampaignBySlug(slug);
if (!campaign) {
return res.status(404).json({ error: 'Campaign not found' });
}
// Check if custom recipients are enabled for this campaign
// Use NocoDB column title, not camelCase
if (!campaign['Allow Custom Recipients']) {
return res.json({ recipients: [], message: 'Custom recipients not enabled for this campaign' });
}
// Get custom recipients for this campaign using slug
const recipients = await nocoDB.getCustomRecipientsBySlug(slug);
res.json({
success: true,
recipients: recipients || [],
count: recipients ? recipients.length : 0
});
} catch (error) {
console.error('Error fetching custom recipients:', error);
next(error);
}
}
/**
* Create a single custom recipient
*/
async createRecipient(req, res, next) {
try {
const { slug } = req.params;
const { recipient_name, recipient_email, recipient_title, recipient_organization, notes } = req.body;
// Validate required fields
if (!recipient_name || !recipient_email) {
return res.status(400).json({ error: 'Recipient name and email are required' });
}
// Validate email format
if (!validateEmail(recipient_email)) {
return res.status(400).json({ error: 'Invalid email format' });
}
// Get campaign to verify it exists and get ID
const campaign = await nocoDB.getCampaignBySlug(slug);
if (!campaign) {
return res.status(404).json({ error: 'Campaign not found' });
}
// Check if custom recipients are enabled for this campaign
// Use NocoDB column title, not camelCase field name
if (!campaign['Allow Custom Recipients']) {
console.warn('Custom recipients not enabled. Campaign data:', campaign);
return res.status(403).json({ error: 'Custom recipients not enabled for this campaign' });
}
// Create the recipient
// Use campaign.ID (NocoDB system field) not campaign.id
const recipientData = {
campaign_id: campaign.ID,
campaign_slug: slug,
recipient_name,
recipient_email,
recipient_title: recipient_title || null,
recipient_organization: recipient_organization || null,
notes: notes || null,
is_active: true
};
const newRecipient = await nocoDB.createCustomRecipient(recipientData);
res.status(201).json({
success: true,
recipient: newRecipient,
message: 'Recipient created successfully'
});
} catch (error) {
console.error('Error creating custom recipient:', error);
next(error);
}
}
/**
* Bulk create custom recipients
*/
async bulkCreateRecipients(req, res, next) {
try {
const { slug } = req.params;
const { recipients } = req.body;
// Validate input
if (!Array.isArray(recipients) || recipients.length === 0) {
return res.status(400).json({ error: 'Recipients array is required and must not be empty' });
}
// Get campaign to verify it exists and get ID
const campaign = await nocoDB.getCampaignBySlug(slug);
if (!campaign) {
return res.status(404).json({ error: 'Campaign not found' });
}
// Check if custom recipients are enabled for this campaign
if (!campaign.allow_custom_recipients) {
return res.status(403).json({ error: 'Custom recipients not enabled for this campaign' });
}
const results = {
success: [],
failed: [],
total: recipients.length
};
// Process each recipient
for (const recipient of recipients) {
try {
// Validate required fields
if (!recipient.recipient_name || !recipient.recipient_email) {
results.failed.push({
recipient,
error: 'Missing required fields (name or email)'
});
continue;
}
// Validate email format
if (!validateEmail(recipient.recipient_email)) {
results.failed.push({
recipient,
error: 'Invalid email format'
});
continue;
}
// Create the recipient
const recipientData = {
campaign_id: campaign.id,
campaign_slug: slug,
recipient_name: recipient.recipient_name,
recipient_email: recipient.recipient_email,
recipient_title: recipient.recipient_title || null,
recipient_organization: recipient.recipient_organization || null,
notes: recipient.notes || null,
is_active: true
};
const newRecipient = await nocoDB.createCustomRecipient(recipientData);
results.success.push(newRecipient);
} catch (error) {
results.failed.push({
recipient,
error: error.message || 'Unknown error'
});
}
}
res.status(201).json({
success: true,
results,
message: `Successfully created ${results.success.length} of ${results.total} recipients`
});
} catch (error) {
console.error('Error bulk creating custom recipients:', error);
next(error);
}
}
/**
* Update a custom recipient
*/
async updateRecipient(req, res, next) {
try {
const { slug, id } = req.params;
const { recipient_name, recipient_email, recipient_title, recipient_organization, notes, is_active } = req.body;
// Validate email if provided
if (recipient_email && !validateEmail(recipient_email)) {
return res.status(400).json({ error: 'Invalid email format' });
}
// Get campaign to verify it exists
const campaign = await nocoDB.getCampaignBySlug(slug);
if (!campaign) {
return res.status(404).json({ error: 'Campaign not found' });
}
// Build update data (only include provided fields)
const updateData = {};
if (recipient_name !== undefined) updateData.recipient_name = recipient_name;
if (recipient_email !== undefined) updateData.recipient_email = recipient_email;
if (recipient_title !== undefined) updateData.recipient_title = recipient_title;
if (recipient_organization !== undefined) updateData.recipient_organization = recipient_organization;
if (notes !== undefined) updateData.notes = notes;
if (is_active !== undefined) updateData.is_active = is_active;
// Update the recipient
const updatedRecipient = await nocoDB.updateCustomRecipient(id, updateData);
if (!updatedRecipient) {
return res.status(404).json({ error: 'Recipient not found' });
}
res.json({
success: true,
recipient: updatedRecipient,
message: 'Recipient updated successfully'
});
} catch (error) {
console.error('Error updating custom recipient:', error);
next(error);
}
}
/**
* Delete a custom recipient
*/
async deleteRecipient(req, res, next) {
try {
const { slug, id } = req.params;
// Get campaign to verify it exists
const campaign = await nocoDB.getCampaignBySlug(slug);
if (!campaign) {
return res.status(404).json({ error: 'Campaign not found' });
}
// Delete the recipient
const deleted = await nocoDB.deleteCustomRecipient(id);
if (!deleted) {
return res.status(404).json({ error: 'Recipient not found' });
}
res.json({
success: true,
message: 'Recipient deleted successfully'
});
} catch (error) {
console.error('Error deleting custom recipient:', error);
next(error);
}
}
/**
* Delete all custom recipients for a campaign
*/
async deleteAllRecipients(req, res, next) {
try {
const { slug } = req.params;
// Get campaign to verify it exists and get ID
const campaign = await nocoDB.getCampaignBySlug(slug);
if (!campaign) {
return res.status(404).json({ error: 'Campaign not found' });
}
// Delete all recipients for this campaign
const deletedCount = await nocoDB.deleteCustomRecipientsByCampaign(campaign.id);
res.json({
success: true,
deletedCount,
message: `Successfully deleted ${deletedCount} recipient(s)`
});
} catch (error) {
console.error('Error deleting all custom recipients:', error);
next(error);
}
}
}
module.exports = new CustomRecipientsController();

View File

@ -0,0 +1,262 @@
const listmonkService = require('../services/listmonk');
const nocodbService = require('../services/nocodb');
const logger = require('../utils/logger');
// Get Listmonk sync status
exports.getSyncStatus = async (req, res) => {
try {
const status = listmonkService.getSyncStatus();
// Also check connection if it's enabled
if (status.enabled && !status.connected) {
// Try to reconnect
const reconnected = await listmonkService.checkConnection();
status.connected = reconnected;
}
res.json(status);
} catch (error) {
logger.error('Failed to get Listmonk status', error);
res.status(500).json({
success: false,
error: 'Failed to get sync status'
});
}
};
// Sync all campaign participants to Listmonk
exports.syncCampaignParticipants = async (req, res) => {
try {
if (!listmonkService.syncEnabled) {
return res.status(400).json({
success: false,
error: 'Listmonk sync is disabled'
});
}
// Get all campaign emails (use campaignEmails table, not emails)
const emailsData = await nocodbService.getAll(nocodbService.tableIds.campaignEmails);
const emails = emailsData?.list || [];
// Get all campaigns for reference
const campaigns = await nocodbService.getAllCampaigns();
if (!emails || emails.length === 0) {
return res.json({
success: true,
message: 'No campaign participants to sync',
results: { total: 0, success: 0, failed: 0, errors: [] }
});
}
const results = await listmonkService.bulkSyncCampaignParticipants(emails, campaigns);
res.json({
success: true,
message: `Campaign participants sync completed: ${results.success} succeeded, ${results.failed} failed`,
results
});
} catch (error) {
logger.error('Campaign participants sync failed', error);
res.status(500).json({
success: false,
error: 'Failed to sync campaign participants to Listmonk'
});
}
};
// Sync all custom recipients to Listmonk
exports.syncCustomRecipients = async (req, res) => {
try {
if (!listmonkService.syncEnabled) {
return res.status(400).json({
success: false,
error: 'Listmonk sync is disabled'
});
}
// Get all custom recipients
const recipientsData = await nocodbService.getAll(nocodbService.tableIds.customRecipients);
const recipients = recipientsData?.list || [];
// Get all campaigns for reference
const campaigns = await nocodbService.getAllCampaigns();
if (!recipients || recipients.length === 0) {
return res.json({
success: true,
message: 'No custom recipients to sync',
results: { total: 0, success: 0, failed: 0, errors: [] }
});
}
const results = await listmonkService.bulkSyncCustomRecipients(recipients, campaigns);
res.json({
success: true,
message: `Custom recipients sync completed: ${results.success} succeeded, ${results.failed} failed`,
results
});
} catch (error) {
logger.error('Custom recipients sync failed', error);
res.status(500).json({
success: false,
error: 'Failed to sync custom recipients to Listmonk'
});
}
};
// Sync everything (participants and custom recipients)
exports.syncAll = async (req, res) => {
try {
if (!listmonkService.syncEnabled) {
return res.status(400).json({
success: false,
error: 'Listmonk sync is disabled'
});
}
let results = {
participants: { total: 0, success: 0, failed: 0, errors: [] },
customRecipients: { total: 0, success: 0, failed: 0, errors: [] }
};
// Get campaigns once for both syncs
const campaigns = await nocodbService.getAllCampaigns();
// Sync campaign participants
try {
const emailsData = await nocodbService.getAll(nocodbService.tableIds.campaignEmails);
const emails = emailsData?.list || [];
if (emails && emails.length > 0) {
results.participants = await listmonkService.bulkSyncCampaignParticipants(emails, campaigns);
}
} catch (error) {
logger.error('Failed to sync campaign participants during full sync', error);
results.participants.errors.push({ error: error.message });
}
// Sync custom recipients
try {
const recipientsData = await nocodbService.getAll(nocodbService.tableIds.customRecipients);
const recipients = recipientsData?.list || [];
if (recipients && recipients.length > 0) {
results.customRecipients = await listmonkService.bulkSyncCustomRecipients(recipients, campaigns);
}
} catch (error) {
logger.error('Failed to sync custom recipients during full sync', error);
results.customRecipients.errors.push({ error: error.message });
}
const totalSuccess = results.participants.success + results.customRecipients.success;
const totalFailed = results.participants.failed + results.customRecipients.failed;
res.json({
success: true,
message: `Complete sync finished: ${totalSuccess} succeeded, ${totalFailed} failed`,
results
});
} catch (error) {
logger.error('Complete sync failed', error);
res.status(500).json({
success: false,
error: 'Failed to perform complete sync'
});
}
};
// Get Listmonk list statistics
exports.getListStats = async (req, res) => {
try {
if (!listmonkService.syncEnabled) {
return res.json({
success: false,
error: 'Listmonk sync is disabled',
stats: null
});
}
const stats = await listmonkService.getListStats();
// Convert stats object to array format for frontend
let statsArray = [];
if (stats && typeof stats === 'object') {
statsArray = Object.entries(stats).map(([key, list]) => ({
id: key,
name: list.name,
subscriberCount: list.subscriber_count || 0,
description: `Email list for ${key}`
}));
}
res.json({
success: true,
stats: statsArray
});
} catch (error) {
logger.error('Failed to get Listmonk list stats', error);
res.status(500).json({
success: false,
error: 'Failed to get list statistics'
});
}
};
// Test Listmonk connection
exports.testConnection = async (req, res) => {
try {
const connected = await listmonkService.checkConnection();
if (connected) {
res.json({
success: true,
message: 'Listmonk connection successful',
connected: true
});
} else {
res.json({
success: false,
message: listmonkService.lastError || 'Connection failed',
connected: false
});
}
} catch (error) {
logger.error('Failed to test Listmonk connection', error);
res.status(500).json({
success: false,
error: 'Failed to test connection'
});
}
};
// Reinitialize Listmonk lists
exports.reinitializeLists = async (req, res) => {
try {
if (!listmonkService.syncEnabled) {
return res.status(400).json({
success: false,
error: 'Listmonk sync is disabled'
});
}
const initialized = await listmonkService.initializeLists();
if (initialized) {
res.json({
success: true,
message: 'Listmonk lists reinitialized successfully'
});
} else {
res.json({
success: false,
message: listmonkService.lastError || 'Failed to initialize lists'
});
}
} catch (error) {
logger.error('Failed to reinitialize Listmonk lists', error);
res.status(500).json({
success: false,
error: 'Failed to reinitialize lists'
});
}
};

View File

@ -61,6 +61,7 @@ const csrfExemptRoutes = [
'/api/metrics', '/api/metrics',
'/api/config', '/api/config',
'/api/auth/login', // Login uses credentials for authentication '/api/auth/login', // Login uses credentials for authentication
'/api/auth/logout', // Logout is an authentication action
'/api/auth/session', // Session check is read-only '/api/auth/session', // Session check is read-only
'/api/representatives/postal/', // Read-only operation '/api/representatives/postal/', // Read-only operation
'/api/campaigns/public' // Public read operations '/api/campaigns/public' // Public read operations
@ -77,12 +78,30 @@ const conditionalCsrfProtection = (req, res, next) => {
return next(); return next();
} }
// Log CSRF validation attempt for debugging
console.log('=== CSRF VALIDATION ===');
console.log('Method:', req.method);
console.log('Path:', req.path);
console.log('Body Token:', req.body?._csrf ? 'YES' : 'NO');
console.log('Header Token:', req.headers['x-csrf-token'] ? 'YES' : 'NO');
console.log('CSRF Cookie:', req.cookies['_csrf'] ? 'YES' : 'NO');
console.log('Session ID:', req.session?.id || 'NO_SESSION');
console.log('=======================');
// Apply CSRF protection for state-changing operations // Apply CSRF protection for state-changing operations
csrfProtection(req, res, (err) => { csrfProtection(req, res, (err) => {
if (err) { if (err) {
return csrfErrorHandler(err, req, res, next); console.log('=== CSRF ERROR ===');
console.log('Error Message:', err.message);
console.log('Error Code:', err.code);
console.log('Path:', req.path);
console.log('==================');
logger.warn('CSRF token validation failed');
csrfErrorHandler(err, req, res, next);
} else {
logger.info('CSRF validation passed for:', req.path);
next();
} }
injectCsrfToken(req, res, next);
}); });
}; };
@ -90,9 +109,28 @@ const conditionalCsrfProtection = (req, res, next) => {
* Helper to get CSRF token for client-side use * Helper to get CSRF token for client-side use
*/ */
const getCsrfToken = (req, res) => { const getCsrfToken = (req, res) => {
try {
// Generate a CSRF token if one doesn't exist
const token = req.csrfToken();
console.log('=== CSRF TOKEN GENERATION ===');
console.log('Token Length:', token?.length || 0);
console.log('Has Token:', !!token);
console.log('Session ID:', req.session?.id || 'NO_SESSION');
console.log('Cookie will be set:', !!req.cookies);
console.log('=============================');
res.json({ res.json({
csrfToken: req.csrfToken() csrfToken: token
}); });
} catch (error) {
console.log('=== CSRF TOKEN ERROR ===');
console.log('Error:', error.message);
console.log('Stack:', error.stack);
console.log('========================');
logger.error('Failed to generate CSRF token', { error: error.message, stack: error.stack });
res.status(500).json({
error: 'Failed to generate CSRF token'
});
}
}; };
module.exports = { module.exports = {

View File

@ -762,6 +762,97 @@
opacity: 0.8; opacity: 0.8;
} }
/* Custom Recipients Styles */
.recipient-card {
background: white;
border: 1px solid #ddd;
border-radius: 8px;
padding: 1rem;
margin-bottom: 0.75rem;
display: flex;
justify-content: space-between;
align-items: flex-start;
transition: box-shadow 0.2s;
}
.recipient-card:hover {
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.recipient-info {
flex: 1;
}
.recipient-info h5 {
margin: 0 0 0.25rem 0;
color: #2c3e50;
font-size: 1rem;
}
.recipient-info .recipient-email {
color: #3498db;
font-size: 0.9rem;
margin-bottom: 0.25rem;
}
.recipient-info .recipient-meta {
color: #666;
font-size: 0.85rem;
margin: 0.25rem 0;
}
.recipient-actions {
display: flex;
gap: 0.5rem;
margin-left: 1rem;
}
.btn-icon {
padding: 0.5rem;
background: none;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
transition: all 0.2s;
}
.btn-icon:hover {
background: #f8f9fa;
transform: scale(1.1);
}
.btn-icon.edit:hover {
border-color: #3498db;
color: #3498db;
}
.btn-icon.delete:hover {
border-color: #e74c3c;
color: #e74c3c;
}
/* Bulk Import Modal */
.bulk-import-help {
background: #e3f2fd;
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.bulk-import-help h4 {
margin: 0 0 0.5rem 0;
color: #1976d2;
}
.bulk-import-help code {
background: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-family: monospace;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.admin-nav { .admin-nav {
flex-direction: column; flex-direction: column;
@ -803,6 +894,7 @@
<button class="nav-btn" data-tab="edit">Edit Campaign</button> <button class="nav-btn" data-tab="edit">Edit Campaign</button>
<button class="nav-btn" data-tab="responses">Response Moderation</button> <button class="nav-btn" data-tab="responses">Response Moderation</button>
<button class="nav-btn" data-tab="users">User Management</button> <button class="nav-btn" data-tab="users">User Management</button>
<button class="nav-btn" data-tab="listmonk">📧 Email Sync</button>
</nav> </nav>
<!-- Success/Error Messages --> <!-- Success/Error Messages -->
@ -918,6 +1010,10 @@ Sincerely,
<input type="checkbox" id="create-allow-editing" name="allow_email_editing"> <input type="checkbox" id="create-allow-editing" name="allow_email_editing">
<label for="create-allow-editing">✏️ Allow Email Editing</label> <label for="create-allow-editing">✏️ Allow Email Editing</label>
</div> </div>
<div class="checkbox-item">
<input type="checkbox" id="create-allow-custom-recipients" name="allow_custom_recipients">
<label for="create-allow-custom-recipients">📧 Allow Custom Recipients</label>
</div>
<div class="checkbox-item"> <div class="checkbox-item">
<input type="checkbox" id="create-show-response-wall" name="show_response_wall"> <input type="checkbox" id="create-show-response-wall" name="show_response_wall">
<label for="create-show-response-wall">💬 Show Response Wall Button</label> <label for="create-show-response-wall">💬 Show Response Wall Button</label>
@ -925,6 +1021,15 @@ Sincerely,
</div> </div>
</div> </div>
<!-- Custom Recipients Management Section (hidden by default) -->
<div id="create-custom-recipients-section" class="section-header" style="display: none;">
<div class="section-header">📧 Manage Custom Recipients</div>
<p style="color: #666; font-size: 0.9rem; margin-bottom: 1rem;">
Add specific people or organizations to target with this campaign.
<strong>Note:</strong> Custom recipients can only be added after the campaign is created.
</p>
</div>
<div class="section-header">🏛️ Target Government Levels</div> <div class="section-header">🏛️ Target Government Levels</div>
<div class="form-group"> <div class="form-group">
<div class="checkbox-group"> <div class="checkbox-group">
@ -1039,6 +1144,10 @@ Sincerely,
<input type="checkbox" id="edit-allow-editing" name="allow_email_editing"> <input type="checkbox" id="edit-allow-editing" name="allow_email_editing">
<label for="edit-allow-editing">✏️ Allow Email Editing</label> <label for="edit-allow-editing">✏️ Allow Email Editing</label>
</div> </div>
<div class="checkbox-item">
<input type="checkbox" id="edit-allow-custom-recipients" name="allow_custom_recipients">
<label for="edit-allow-custom-recipients">📧 Allow Custom Recipients</label>
</div>
<div class="checkbox-item"> <div class="checkbox-item">
<input type="checkbox" id="edit-show-response-wall" name="show_response_wall"> <input type="checkbox" id="edit-show-response-wall" name="show_response_wall">
<label for="edit-show-response-wall">💬 Show Response Wall Button</label> <label for="edit-show-response-wall">💬 Show Response Wall Button</label>
@ -1073,6 +1182,67 @@ Sincerely,
<button type="button" class="btn btn-secondary" data-action="cancel-edit">Cancel</button> <button type="button" class="btn btn-secondary" data-action="cancel-edit">Cancel</button>
</div> </div>
</form> </form>
<!-- Custom Recipients Management Section (Outside the form to avoid nested forms) -->
<div id="edit-custom-recipients-section" style="display: none; margin-top: 2rem;">
<div class="section-header">📧 Manage Custom Recipients</div>
<p style="color: #666; font-size: 0.9rem; margin-bottom: 1rem;">
Add specific people or organizations to target with this campaign instead of (or in addition to) elected representatives.
</p>
<!-- Add Single Recipient Form -->
<form id="add-recipient-form" class="form-grid" style="background: #f8f9fa; padding: 1.5rem; border-radius: 8px; margin-bottom: 1rem;">
<h4 style="grid-column: 1 / -1; margin-top: 0;">Add New Recipient</h4>
<div class="form-group">
<label for="recipient-name">Recipient Name *</label>
<input type="text" id="recipient-name" name="recipient_name" placeholder="John Smith" required>
</div>
<div class="form-group">
<label for="recipient-email">Email Address *</label>
<input type="email" id="recipient-email" name="recipient_email" placeholder="john.smith@example.com" required>
</div>
<div class="form-group">
<label for="recipient-title">Title/Position</label>
<input type="text" id="recipient-title" name="recipient_title" placeholder="CEO, Director, etc.">
</div>
<div class="form-group">
<label for="recipient-organization">Organization</label>
<input type="text" id="recipient-organization" name="recipient_organization" placeholder="Company or Organization Name">
</div>
<div class="form-group" style="grid-column: 1 / -1;">
<label for="recipient-notes">Notes (optional)</label>
<textarea id="recipient-notes" name="notes" rows="2" placeholder="Internal notes about this recipient..."></textarea>
</div>
<div class="form-row" style="grid-column: 1 / -1; margin: 0;">
<button type="submit" class="btn btn-primary">
Add Recipient
</button>
<button type="button" id="bulk-import-recipients-btn" class="btn btn-secondary">
📥 Bulk Import (CSV)
</button>
</div>
</form>
<!-- Recipients List -->
<div id="recipients-list-container">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h4 style="margin: 0;">Current Recipients</h4>
<button type="button" id="clear-all-recipients-btn" class="btn btn-danger btn-small">
🗑️ Clear All
</button>
</div>
<div id="recipients-list">
<!-- Recipients will be loaded here dynamically -->
</div>
</div>
</div>
</div> </div>
<!-- Response Moderation Tab --> <!-- Response Moderation Tab -->
@ -1124,6 +1294,80 @@ Sincerely,
<!-- Users will be loaded here --> <!-- Users will be loaded here -->
</div> </div>
</div> </div>
<!-- Listmonk Email Sync Tab -->
<div id="listmonk-tab" class="tab-content">
<div class="form-row" style="align-items: center; margin-bottom: 2rem;">
<h2 style="margin: 0;">📧 Email List Synchronization</h2>
<button class="btn btn-secondary" id="refresh-status-btn" onclick="refreshListmonkStatus()">🔄 Refresh Status</button>
</div>
<div class="section-header">📊 Sync Status</div>
<div id="sync-status-display" style="background: #f8f9fa; padding: 1.5rem; border-radius: 8px; margin-bottom: 2rem;">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1rem; margin-bottom: 1rem;">
<div style="background: white; padding: 1rem; border-radius: 8px; border: 2px solid #e9ecef;">
<strong style="color: #2c3e50; display: block; margin-bottom: 0.5rem;">Connection Status:</strong>
<span id="connection-status" style="font-size: 1.1rem;">⏳ Checking...</span>
</div>
<div style="background: white; padding: 1rem; border-radius: 8px; border: 2px solid #e9ecef;">
<strong style="color: #2c3e50; display: block; margin-bottom: 0.5rem;">Auto-Sync:</strong>
<span id="autosync-status" style="font-size: 1.1rem;">⏳ Checking...</span>
</div>
</div>
<div id="last-error" style="display: none; background: #f8d7da; color: #721c24; padding: 1rem; border-radius: 8px; border: 1px solid #f5c6cb;">
<!-- Error message will be displayed here -->
</div>
</div>
<div class="section-header">🚀 Sync Actions</div>
<div style="background: white; padding: 1.5rem; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.05); margin-bottom: 2rem;">
<p style="color: #666; margin-bottom: 1.5rem;">Sync campaign participants and custom recipients to Listmonk email lists for targeted email campaigns.</p>
<div class="sync-buttons" style="display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem;">
<button class="btn btn-primary" id="sync-participants-btn" onclick="syncToListmonk('participants')">
👥 Sync Campaign Participants
</button>
<button class="btn btn-primary" id="sync-recipients-btn" onclick="syncToListmonk('recipients')">
📋 Sync Custom Recipients
</button>
<button class="btn btn-primary" id="sync-all-btn" onclick="syncToListmonk('all')">
🔄 Sync All
</button>
</div>
<div id="sync-progress" style="display: none; margin-top: 1.5rem;">
<div style="background: #f8f9fa; border-radius: 8px; overflow: hidden; height: 30px; margin-bottom: 1rem;">
<div id="sync-progress-bar" style="background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); height: 100%; width: 0%; transition: width 0.3s; display: flex; align-items: center; justify-content: center; color: white; font-weight: 600; font-size: 0.85rem;">
</div>
</div>
<div id="sync-results" style="background: #e3f2fd; padding: 1rem; border-radius: 8px; border-left: 4px solid #2196f3;">
<!-- Sync results will be displayed here -->
</div>
</div>
</div>
<div class="section-header">⚙️ Advanced Options</div>
<div style="background: white; padding: 1.5rem; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.05); margin-bottom: 2rem;">
<div style="display: flex; gap: 1rem; flex-wrap: wrap;">
<button class="btn btn-secondary" id="test-connection-btn" onclick="testListmonkConnection()">
🔌 Test Connection
</button>
<button class="btn btn-secondary" id="reinitialize-lists-btn" onclick="reinitializeListmonk()" style="background: #f39c12;">
⚠️ Reinitialize Lists
</button>
</div>
<p style="color: #666; margin-top: 1rem; font-size: 0.9rem;">
<strong>Note:</strong> Reinitializing lists will recreate all email list structures. Use only if lists are corrupted or missing.
</p>
</div>
<div class="section-header">📈 Email List Statistics</div>
<div id="listmonk-stats-section" style="background: white; padding: 1.5rem; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.05);">
<div class="stats-list">
<p style="color: #666; text-align: center;">Loading statistics...</p>
</div>
</div>
</div>
</div> </div>
<!-- User Modal --> <!-- User Modal -->
@ -1199,8 +1443,54 @@ Sincerely,
</div> </div>
</div> </div>
<!-- Bulk Import Recipients Modal -->
<div id="bulk-import-modal" class="modal-overlay hidden">
<div class="modal-content">
<div class="modal-header">
<h3>Bulk Import Recipients</h3>
<button class="modal-close" data-action="close-bulk-import-modal">&times;</button>
</div>
<div class="bulk-import-help">
<h4>📋 CSV Format</h4>
<p>Upload or paste CSV data with the following columns:</p>
<code>recipient_name,recipient_email,recipient_title,recipient_organization,notes</code>
<p style="margin-top: 0.5rem;"><strong>Example:</strong></p>
<code style="display: block; white-space: pre; font-size: 0.85rem;">John Smith,john@example.com,CEO,Acme Corp,Important contact
Jane Doe,jane@example.com,Director,Example Inc,</code>
</div>
<form id="bulk-import-form">
<div class="form-group">
<label for="bulk-csv-file">Upload CSV File</label>
<input type="file" id="bulk-csv-file" accept=".csv,text/csv">
</div>
<div style="text-align: center; margin: 1rem 0; color: #666;">
<strong>- OR -</strong>
</div>
<div class="form-group">
<label for="bulk-csv-text">Paste CSV Data</label>
<textarea id="bulk-csv-text" rows="8" placeholder="Paste your CSV data here..."></textarea>
</div>
<div class="form-row">
<button type="submit" class="btn btn-primary">Import Recipients</button>
<button type="button" class="btn btn-secondary" data-action="close-bulk-import-modal">Cancel</button>
</div>
</form>
<div id="bulk-import-results" class="hidden" style="margin-top: 1rem;">
<!-- Results will be shown here -->
</div>
</div>
</div>
<script src="js/api-client.js"></script> <script src="js/api-client.js"></script>
<script src="js/auth.js"></script> <script src="js/auth.js"></script>
<script src="js/custom-recipients.js"></script>
<script src="js/listmonk-admin.js"></script>
<script src="js/admin.js"></script> <script src="js/admin.js"></script>
</body> </body>

View File

@ -303,6 +303,11 @@
transition: transform 0.2s, box-shadow 0.2s; transition: transform 0.2s, box-shadow 0.2s;
} }
.rep-card.custom-recipient {
border-left: 4px solid #9b59b6;
background: linear-gradient(135deg, #ffffff 0%, #f8f5fb 100%);
}
.rep-card:hover { .rep-card:hover {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1); box-shadow: 0 4px 12px rgba(0,0,0,0.1);
@ -321,11 +326,30 @@
border-radius: 50%; border-radius: 50%;
object-fit: cover; object-fit: cover;
background: #e9ecef; background: #e9ecef;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
}
.custom-badge {
display: inline-block;
margin-left: 0.5rem;
padding: 0.25rem 0.5rem;
background: linear-gradient(135deg, #9b59b6, #8e44ad);
color: white;
border-radius: 4px;
font-size: 0.75rem;
font-weight: normal;
vertical-align: middle;
} }
.rep-details h4 { .rep-details h4 {
margin: 0 0 0.25rem 0; margin: 0 0 0.25rem 0;
color: #2c3e50; color: #2c3e50;
display: flex;
align-items: center;
gap: 0.5rem;
} }
.rep-details p { .rep-details p {
@ -764,9 +788,13 @@
<footer style="text-align: center; margin-top: 2rem; padding: 1rem; border-top: 1px solid #e9ecef;"> <footer style="text-align: center; margin-top: 2rem; padding: 1rem; border-top: 1px solid #e9ecef;">
<p>&copy; 2025 <a href="https://bnkops.com/" target="_blank">BNKops</a> Influence Tool. Connect with democracy.</p> <p>&copy; 2025 <a href="https://bnkops.com/" target="_blank">BNKops</a> Influence Tool. Connect with democracy.</p>
<p><small><a href="/terms.html" id="terms-link" target="_blank">Terms of Use & Privacy Notice</a> | <a href="/index.html" id="home-link">Return to Main Page</a></small></p> <p><small><a href="/terms.html" id="terms-link" target="_blank">Terms of Use & Privacy Notice</a></small></p>
<div style="margin-top: 1rem;">
<a href="/index.html" id="home-link" class="btn btn-secondary">Return to Main Page</a>
</div>
</footer> </footer>
<script src="/js/api-client.js"></script>
<script src="/js/campaign.js"></script> <script src="/js/campaign.js"></script>
<script> <script>
// Update footer links with APP_URL if needed for cross-origin scenarios // Update footer links with APP_URL if needed for cross-origin scenarios

View File

@ -1913,6 +1913,23 @@ footer a:hover {
color: #666; color: #666;
} }
/* Verified response badge styling */
.campaign-card-stat.verified-response {
background: linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%);
border: 1px solid #81c784;
margin: 12px 0;
width: 100%;
}
.campaign-card-stat.verified-response .stat-value {
color: #2e7d32;
}
.campaign-card-stat.verified-response .stat-label {
color: #1b5e20;
font-weight: 500;
}
.campaign-card-social-share { .campaign-card-social-share {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -8,6 +8,7 @@ class AdminPanel {
} }
async init() { async init() {
console.log('AdminPanel init started');
// Check authentication first // Check authentication first
if (typeof authManager !== 'undefined') { if (typeof authManager !== 'undefined') {
this.authManager = authManager; this.authManager = authManager;
@ -23,9 +24,12 @@ class AdminPanel {
return; return;
} }
console.log('Setting up event listeners and form interactions');
this.setupEventListeners(); this.setupEventListeners();
this.setupFormInteractions(); this.setupFormInteractions();
this.setupCustomRecipientsHandlers();
this.loadCampaigns(); this.loadCampaigns();
console.log('AdminPanel init completed');
} }
setupUserInterface() { setupUserInterface() {
@ -573,6 +577,7 @@ class AdminPanel {
campaignFormData.append('show_email_count', formData.get('show_email_count') === 'on'); campaignFormData.append('show_email_count', formData.get('show_email_count') === 'on');
campaignFormData.append('allow_email_editing', formData.get('allow_email_editing') === 'on'); campaignFormData.append('allow_email_editing', formData.get('allow_email_editing') === 'on');
campaignFormData.append('show_response_wall', formData.get('show_response_wall') === 'on'); campaignFormData.append('show_response_wall', formData.get('show_response_wall') === 'on');
campaignFormData.append('allow_custom_recipients', formData.get('allow_custom_recipients') === 'on');
// Handle target_government_levels array // Handle target_government_levels array
const targetLevels = Array.from(formData.getAll('target_government_levels')); const targetLevels = Array.from(formData.getAll('target_government_levels'));
@ -645,6 +650,28 @@ class AdminPanel {
form.querySelector('[name="show_email_count"]').checked = campaign.show_email_count; form.querySelector('[name="show_email_count"]').checked = campaign.show_email_count;
form.querySelector('[name="allow_email_editing"]').checked = campaign.allow_email_editing; form.querySelector('[name="allow_email_editing"]').checked = campaign.allow_email_editing;
form.querySelector('[name="show_response_wall"]').checked = campaign.show_response_wall; form.querySelector('[name="show_response_wall"]').checked = campaign.show_response_wall;
form.querySelector('[name="allow_custom_recipients"]').checked = campaign.allow_custom_recipients || false;
// Show/hide custom recipients section based on checkbox
this.toggleCustomRecipientsSection(campaign.allow_custom_recipients);
// Load custom recipients if enabled
if (campaign.allow_custom_recipients && window.CustomRecipients) {
console.log('Campaign has custom recipients enabled, initializing module...');
console.log('Campaign slug:', campaign.slug);
// Use setTimeout to ensure the section is visible before loading
setTimeout(() => {
console.log('Calling CustomRecipients.init() and loadRecipients()');
window.CustomRecipients.init(campaign.slug);
window.CustomRecipients.loadRecipients(campaign.slug);
}, 200);
} else {
console.log('Custom recipients not enabled or module not loaded:', {
allow_custom_recipients: campaign.allow_custom_recipients,
moduleLoaded: !!window.CustomRecipients
});
}
// Government levels // Government levels
let targetLevels = []; let targetLevels = [];
@ -682,6 +709,7 @@ class AdminPanel {
updateFormData.append('show_email_count', formData.get('show_email_count') === 'on'); updateFormData.append('show_email_count', formData.get('show_email_count') === 'on');
updateFormData.append('allow_email_editing', formData.get('allow_email_editing') === 'on'); updateFormData.append('allow_email_editing', formData.get('allow_email_editing') === 'on');
updateFormData.append('show_response_wall', formData.get('show_response_wall') === 'on'); updateFormData.append('show_response_wall', formData.get('show_response_wall') === 'on');
updateFormData.append('allow_custom_recipients', formData.get('allow_custom_recipients') === 'on');
// Handle target_government_levels array // Handle target_government_levels array
const targetLevels = Array.from(formData.getAll('target_government_levels')); const targetLevels = Array.from(formData.getAll('target_government_levels'));
@ -1329,6 +1357,53 @@ class AdminPanel {
div.textContent = text; div.textContent = text;
return div.innerHTML; return div.innerHTML;
} }
toggleCustomRecipientsSection(show) {
const section = document.getElementById('edit-custom-recipients-section');
console.log('Toggling custom recipients section:', show, 'section found:', section);
if (section) {
section.style.display = show ? 'block' : 'none';
console.log('Section display set to:', section.style.display);
}
}
setupCustomRecipientsHandlers() {
console.log('Setting up custom recipients handlers');
// Use event delegation on the document for the checkbox
// This way it will work even if the checkbox is added dynamically
document.addEventListener('change', (e) => {
// Handle edit form checkbox
if (e.target.id === 'edit-allow-custom-recipients') {
console.log('Custom recipients checkbox changed:', e.target.checked);
this.toggleCustomRecipientsSection(e.target.checked);
// Initialize custom recipients module if enabled
if (e.target.checked && this.currentCampaign && window.CustomRecipients) {
console.log('Initializing CustomRecipients module for campaign:', this.currentCampaign.slug);
window.CustomRecipients.init(this.currentCampaign.slug);
window.CustomRecipients.loadRecipients(this.currentCampaign.slug);
}
}
// Handle create form checkbox
if (e.target.id === 'create-allow-custom-recipients') {
console.log('Create form: Custom recipients checkbox changed:', e.target.checked);
this.toggleCreateCustomRecipientsInfo(e.target.checked);
}
});
console.log('Custom recipients handlers set up with event delegation');
}
toggleCreateCustomRecipientsInfo(show) {
const section = document.getElementById('create-custom-recipients-section');
console.log('Toggling create custom recipients info:', show, 'section found:', section);
if (section) {
section.style.display = show ? 'block' : 'none';
console.log('Create section display set to:', section.style.display);
}
}
} }
// Initialize admin panel when DOM is loaded // Initialize admin panel when DOM is loaded

View File

@ -2,14 +2,66 @@
class APIClient { class APIClient {
constructor() { constructor() {
this.baseURL = '/api'; this.baseURL = '/api';
this.csrfToken = null;
this.csrfTokenPromise = null;
}
/**
* Fetch CSRF token from the server
*/
async fetchCsrfToken() {
// If we're already fetching, return the existing promise
if (this.csrfTokenPromise) {
return this.csrfTokenPromise;
}
this.csrfTokenPromise = (async () => {
try {
console.log('Fetching CSRF token from server...');
const response = await fetch(`${this.baseURL}/csrf-token`, {
credentials: 'include' // Important: include cookies
});
const data = await response.json();
this.csrfToken = data.csrfToken;
console.log('CSRF token received:', this.csrfToken ? 'Token obtained' : 'No token');
return this.csrfToken;
} catch (error) {
console.error('Failed to fetch CSRF token:', error);
this.csrfToken = null;
throw error;
} finally {
this.csrfTokenPromise = null;
}
})();
return this.csrfTokenPromise;
}
/**
* Ensure we have a valid CSRF token
*/
async ensureCsrfToken() {
if (!this.csrfToken) {
await this.fetchCsrfToken();
}
return this.csrfToken;
}
async makeRequest(endpoint, options = {}, isRetry = false) {
// For state-changing methods, ensure we have a CSRF token
const needsCsrf = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(options.method);
if (needsCsrf) {
await this.ensureCsrfToken();
} }
async makeRequest(endpoint, options = {}) {
const config = { const config = {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...(needsCsrf && this.csrfToken ? { 'X-CSRF-Token': this.csrfToken } : {}),
...options.headers ...options.headers
}, },
credentials: 'include', // Important: include cookies for CSRF
...options ...options
}; };
@ -17,7 +69,21 @@ class APIClient {
const response = await fetch(`${this.baseURL}${endpoint}`, config); const response = await fetch(`${this.baseURL}${endpoint}`, config);
const data = await response.json(); const data = await response.json();
// If response includes a new CSRF token, update it
if (data.csrfToken) {
this.csrfToken = data.csrfToken;
}
if (!response.ok) { if (!response.ok) {
// If CSRF token is invalid and we haven't retried yet, fetch a new one and retry once
if (!isRetry && response.status === 403 && data.error && data.error.includes('CSRF')) {
console.log('CSRF token invalid, fetching new token and retrying...');
this.csrfToken = null;
await this.fetchCsrfToken();
// Retry the request once with new token
return this.makeRequest(endpoint, options, true);
}
// Create enhanced error with response data for better error handling // Create enhanced error with response data for better error handling
const error = new Error(data.error || data.message || `HTTP ${response.status}`); const error = new Error(data.error || data.message || `HTTP ${response.status}`);
error.status = response.status; error.status = response.status;
@ -65,18 +131,49 @@ class APIClient {
}); });
} }
async postFormData(endpoint, formData) { async postFormData(endpoint, formData, isRetry = false) {
// Ensure we have a CSRF token for POST requests
await this.ensureCsrfToken();
console.log('Sending FormData with CSRF token:', this.csrfToken ? 'Token present' : 'No token');
// Add CSRF token to form data AND headers
if (this.csrfToken) {
formData.set('_csrf', this.csrfToken);
}
// Don't set Content-Type header - browser will set it with boundary for multipart/form-data // Don't set Content-Type header - browser will set it with boundary for multipart/form-data
// But DO set CSRF token header
const config = { const config = {
method: 'POST', method: 'POST',
body: formData headers: {
...(this.csrfToken ? { 'X-CSRF-Token': this.csrfToken } : {})
},
body: formData,
credentials: 'include' // Important: include cookies for CSRF
}; };
try { try {
const response = await fetch(`${this.baseURL}${endpoint}`, config); const response = await fetch(`${this.baseURL}${endpoint}`, config);
const data = await response.json(); const data = await response.json();
// If response includes a new CSRF token, update it
if (data.csrfToken) {
this.csrfToken = data.csrfToken;
}
if (!response.ok) { if (!response.ok) {
// If CSRF token is invalid and we haven't retried yet, fetch a new one and retry once
if (!isRetry && response.status === 403 && data.error && data.error.includes('CSRF')) {
console.log('CSRF token invalid, fetching new token and retrying...');
this.csrfToken = null;
await this.fetchCsrfToken();
// Update form data with new token
formData.set('_csrf', this.csrfToken);
// Retry the request once with new token
return this.postFormData(endpoint, formData, true);
}
const error = new Error(data.error || data.message || `HTTP ${response.status}`); const error = new Error(data.error || data.message || `HTTP ${response.status}`);
error.status = response.status; error.status = response.status;
error.data = data; error.data = data;
@ -90,18 +187,47 @@ class APIClient {
} }
} }
async putFormData(endpoint, formData) { async putFormData(endpoint, formData, isRetry = false) {
// Ensure we have a CSRF token for PUT requests
await this.ensureCsrfToken();
// Add CSRF token to form data AND headers
if (this.csrfToken) {
formData.set('_csrf', this.csrfToken);
}
// Don't set Content-Type header - browser will set it with boundary for multipart/form-data // Don't set Content-Type header - browser will set it with boundary for multipart/form-data
// But DO set CSRF token header
const config = { const config = {
method: 'PUT', method: 'PUT',
body: formData headers: {
...(this.csrfToken ? { 'X-CSRF-Token': this.csrfToken } : {})
},
body: formData,
credentials: 'include' // Important: include cookies for CSRF
}; };
try { try {
const response = await fetch(`${this.baseURL}${endpoint}`, config); const response = await fetch(`${this.baseURL}${endpoint}`, config);
const data = await response.json(); const data = await response.json();
// If response includes a new CSRF token, update it
if (data.csrfToken) {
this.csrfToken = data.csrfToken;
}
if (!response.ok) { if (!response.ok) {
// If CSRF token is invalid and we haven't retried yet, fetch a new one and retry once
if (!isRetry && response.status === 403 && data.error && data.error.includes('CSRF')) {
console.log('CSRF token invalid, fetching new token and retrying...');
this.csrfToken = null;
await this.fetchCsrfToken();
// Update form data with new token
formData.set('_csrf', this.csrfToken);
// Retry the request once with new token
return this.putFormData(endpoint, formData, true);
}
const error = new Error(data.error || data.message || `HTTP ${response.status}`); const error = new Error(data.error || data.message || `HTTP ${response.status}`);
error.status = response.status; error.status = response.status;
error.data = data; error.data = data;

View File

@ -279,8 +279,7 @@ class CampaignPage {
this.showLoading('Loading campaign...'); this.showLoading('Loading campaign...');
try { try {
const response = await fetch(`/api/campaigns/${this.campaignSlug}`); const data = await window.apiClient.get(`/campaigns/${this.campaignSlug}`);
const data = await response.json();
if (!data.success) { if (!data.success) {
throw new Error(data.error || 'Failed to load campaign'); throw new Error(data.error || 'Failed to load campaign');
@ -587,20 +586,12 @@ class CampaignPage {
async trackUserInfo() { async trackUserInfo() {
try { try {
const response = await fetch(`/api/campaigns/${this.campaignSlug}/track-user`, { const data = await window.apiClient.post(`/campaigns/${this.campaignSlug}/track-user`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
userEmail: this.userInfo.userEmail, userEmail: this.userInfo.userEmail,
userName: this.userInfo.userName, userName: this.userInfo.userName,
postalCode: this.userInfo.postalCode postalCode: this.userInfo.postalCode
})
}); });
const data = await response.json();
if (!data.success) { if (!data.success) {
console.warn('Failed to track user info:', data.error); console.warn('Failed to track user info:', data.error);
// Don't throw error - this is just tracking, shouldn't block the user // Don't throw error - this is just tracking, shouldn't block the user
@ -615,8 +606,7 @@ class CampaignPage {
this.showLoading('Finding your representatives...'); this.showLoading('Finding your representatives...');
try { try {
const response = await fetch(`/api/campaigns/${this.campaignSlug}/representatives/${this.userInfo.postalCode}`); const data = await window.apiClient.get(`/campaigns/${this.campaignSlug}/representatives/${this.userInfo.postalCode}`);
const data = await response.json();
if (!data.success) { if (!data.success) {
throw new Error(data.error || 'Failed to load representatives'); throw new Error(data.error || 'Failed to load representatives');
@ -646,14 +636,17 @@ class CampaignPage {
} }
list.innerHTML = this.representatives.map(rep => ` list.innerHTML = this.representatives.map(rep => `
<div class="rep-card"> <div class="rep-card ${rep.is_custom_recipient ? 'custom-recipient' : ''}">
<div class="rep-info"> <div class="rep-info">
${rep.photo_url ? ${rep.photo_url ?
`<img src="${rep.photo_url}" alt="${rep.name}" class="rep-photo">` : `<img src="${rep.photo_url}" alt="${rep.name}" class="rep-photo">` :
`<div class="rep-photo"></div>` `<div class="rep-photo">${rep.is_custom_recipient ? '👤' : ''}</div>`
} }
<div class="rep-details"> <div class="rep-details">
<h4>${rep.name}</h4> <h4>
${rep.name}
${rep.is_custom_recipient ? '<span class="custom-badge" title="Custom Recipient">✉️</span>' : ''}
</h4>
<p>${rep.elected_office || 'Representative'}</p> <p>${rep.elected_office || 'Representative'}</p>
<p>${rep.party_name || ''}</p> <p>${rep.party_name || ''}</p>
${rep.email ? `<p>📧 ${rep.email}</p>` : ''} ${rep.email ? `<p>📧 ${rep.email}</p>` : ''}
@ -666,11 +659,12 @@ class CampaignPage {
data-email="${rep.email}" data-email="${rep.email}"
data-name="${rep.name}" data-name="${rep.name}"
data-title="${rep.elected_office || ''}" data-title="${rep.elected_office || ''}"
data-level="${this.getGovernmentLevel(rep)}"> data-level="${this.getGovernmentLevel(rep)}"
data-is-custom="${rep.is_custom_recipient || false}">
Send Email Send Email
</button> </button>
` : ''} ` : ''}
${this.getPhoneNumber(rep) ? ` ${this.getPhoneNumber(rep) && !rep.is_custom_recipient ? `
<button class="btn btn-success" data-action="call-representative" <button class="btn btn-success" data-action="call-representative"
data-phone="${this.getPhoneNumber(rep)}" data-phone="${this.getPhoneNumber(rep)}"
data-name="${rep.name}" data-name="${rep.name}"
@ -698,7 +692,8 @@ class CampaignPage {
const name = e.target.dataset.name; const name = e.target.dataset.name;
const title = e.target.dataset.title; const title = e.target.dataset.title;
const level = e.target.dataset.level; const level = e.target.dataset.level;
this.sendEmail(email, name, title, level); const isCustom = e.target.dataset.isCustom === 'true';
this.sendEmail(email, name, title, level, isCustom);
}); });
}); });
@ -772,12 +767,7 @@ class CampaignPage {
async trackCall(phone, name, title, officeType) { async trackCall(phone, name, title, officeType) {
try { try {
const response = await fetch(`/api/campaigns/${this.campaignSlug}/track-call`, { const data = await window.apiClient.post(`/campaigns/${this.campaignSlug}/track-call`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
representativeName: name, representativeName: name,
representativeTitle: title || '', representativeTitle: title || '',
phoneNumber: phone, phoneNumber: phone,
@ -785,10 +775,7 @@ class CampaignPage {
userEmail: this.userInfo.userEmail, userEmail: this.userInfo.userEmail,
userName: this.userInfo.userName, userName: this.userInfo.userName,
postalCode: this.userInfo.postalCode postalCode: this.userInfo.postalCode
})
}); });
const data = await response.json();
if (data.success) { if (data.success) {
this.showCallSuccess('Call tracked successfully!'); this.showCallSuccess('Call tracked successfully!');
} }
@ -797,23 +784,26 @@ class CampaignPage {
} }
} }
async sendEmail(recipientEmail, recipientName, recipientTitle, recipientLevel) { async sendEmail(recipientEmail, recipientName, recipientTitle, recipientLevel, isCustom = false) {
const emailMethod = document.querySelector('input[name="emailMethod"]:checked').value; const emailMethod = document.querySelector('input[name="emailMethod"]:checked').value;
// Use "Custom Recipient" as level if this is a custom recipient and no level provided
const finalLevel = isCustom && !recipientLevel ? 'Custom Recipient' : recipientLevel;
if (emailMethod === 'mailto') { if (emailMethod === 'mailto') {
this.openMailtoLink(recipientEmail); this.openMailtoLink(recipientEmail, recipientName, recipientTitle, finalLevel);
} else { } else {
await this.sendSMTPEmail(recipientEmail, recipientName, recipientTitle, recipientLevel); await this.sendSMTPEmail(recipientEmail, recipientName, recipientTitle, finalLevel);
} }
} }
openMailtoLink(recipientEmail) { openMailtoLink(recipientEmail, recipientName, recipientTitle, recipientLevel) {
const subject = encodeURIComponent(this.currentEmailSubject || this.campaign.email_subject); const subject = encodeURIComponent(this.currentEmailSubject || this.campaign.email_subject);
const body = encodeURIComponent(this.currentEmailBody || this.campaign.email_body); const body = encodeURIComponent(this.currentEmailBody || this.campaign.email_body);
const mailtoUrl = `mailto:${recipientEmail}?subject=${subject}&body=${body}`; const mailtoUrl = `mailto:${recipientEmail}?subject=${subject}&body=${body}`;
// Track the mailto click // Track the mailto click
this.trackEmail(recipientEmail, '', '', '', 'mailto'); this.trackEmail(recipientEmail, recipientName, recipientTitle, recipientLevel, 'mailto');
window.open(mailtoUrl); window.open(mailtoUrl);
} }
@ -839,15 +829,7 @@ class CampaignPage {
emailData.customEmailBody = this.currentEmailBody || this.campaign.email_body; emailData.customEmailBody = this.currentEmailBody || this.campaign.email_body;
} }
const response = await fetch(`/api/campaigns/${this.campaignSlug}/send-email`, { const data = await window.apiClient.post(`/campaigns/${this.campaignSlug}/send-email`, emailData);
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(emailData)
});
const data = await response.json();
if (data.success) { if (data.success) {
this.showSuccess('Email sent successfully!'); this.showSuccess('Email sent successfully!');
@ -870,12 +852,7 @@ class CampaignPage {
async trackEmail(recipientEmail, recipientName, recipientTitle, recipientLevel, emailMethod) { async trackEmail(recipientEmail, recipientName, recipientTitle, recipientLevel, emailMethod) {
try { try {
await fetch(`/api/campaigns/${this.campaignSlug}/send-email`, { await window.apiClient.post(`/campaigns/${this.campaignSlug}/send-email`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
userEmail: this.userInfo.userEmail, userEmail: this.userInfo.userEmail,
userName: this.userInfo.userName, userName: this.userInfo.userName,
postalCode: this.userInfo.postalCode, postalCode: this.userInfo.postalCode,
@ -884,7 +861,6 @@ class CampaignPage {
recipientTitle, recipientTitle,
recipientLevel, recipientLevel,
emailMethod emailMethod
})
}); });
} catch (error) { } catch (error) {
console.error('Failed to track email:', error); console.error('Failed to track email:', error);

View File

@ -159,6 +159,14 @@ class CampaignsGrid {
</div>` </div>`
: ''; : '';
const verifiedResponseBadge = campaign.verifiedResponseCount > 0
? `<div class="campaign-card-stat verified-response">
<span class="stat-icon"></span>
<span class="stat-value">${campaign.verifiedResponseCount}</span>
<span class="stat-label">verified ${campaign.verifiedResponseCount === 1 ? 'response' : 'responses'}</span>
</div>`
: '';
const targetLevels = Array.isArray(campaign.target_government_levels) && campaign.target_government_levels.length > 0 const targetLevels = Array.isArray(campaign.target_government_levels) && campaign.target_government_levels.length > 0
? campaign.target_government_levels.map(level => `<span class="level-badge">${level}</span>`).join('') ? campaign.target_government_levels.map(level => `<span class="level-badge">${level}</span>`).join('')
: ''; : '';
@ -215,6 +223,7 @@ class CampaignsGrid {
</svg> </svg>
</button> </button>
</div> </div>
${verifiedResponseBadge}
<div class="campaign-card-action"> <div class="campaign-card-action">
<span class="btn-link">Learn More & Participate </span> <span class="btn-link">Learn More & Participate </span>
</div> </div>

View File

@ -0,0 +1,525 @@
/**
* Custom Recipients Management Module
* Handles CRUD operations for custom email recipients in campaigns
*/
console.log('Custom Recipients module loading...');
const CustomRecipients = (() => {
console.log('Custom Recipients module initialized');
let currentCampaignSlug = null;
let recipients = [];
/**
* Initialize the module with a campaign slug
*/
function init(campaignSlug) {
console.log('CustomRecipients.init() called with slug:', campaignSlug);
currentCampaignSlug = campaignSlug;
// Setup event listeners every time init is called
// Use setTimeout to ensure DOM is ready
setTimeout(() => {
setupEventListeners();
console.log('CustomRecipients event listeners set up');
}, 100);
}
/**
* Setup event listeners for custom recipients UI
*/
function setupEventListeners() {
console.log('Setting up CustomRecipients event listeners');
// Add recipient form submit
const addForm = document.getElementById('add-recipient-form');
console.log('Add recipient form found:', addForm);
if (addForm) {
// Remove any existing listener first
addForm.removeEventListener('submit', handleAddRecipient);
addForm.addEventListener('submit', handleAddRecipient);
console.log('Form submit listener attached');
}
// Bulk import button
const bulkImportBtn = document.getElementById('bulk-import-recipients-btn');
if (bulkImportBtn) {
bulkImportBtn.removeEventListener('click', openBulkImportModal);
bulkImportBtn.addEventListener('click', openBulkImportModal);
}
// Clear all recipients button
const clearAllBtn = document.getElementById('clear-all-recipients-btn');
if (clearAllBtn) {
clearAllBtn.removeEventListener('click', handleClearAll);
clearAllBtn.addEventListener('click', handleClearAll);
}
// Bulk import modal buttons
const importBtn = document.getElementById('import-recipients-btn');
if (importBtn) {
importBtn.removeEventListener('click', handleBulkImport);
importBtn.addEventListener('click', handleBulkImport);
}
const cancelBtn = document.querySelector('#bulk-import-modal .cancel');
if (cancelBtn) {
cancelBtn.removeEventListener('click', closeBulkImportModal);
cancelBtn.addEventListener('click', closeBulkImportModal);
}
// Close modal on backdrop click
const modal = document.getElementById('bulk-import-modal');
if (modal) {
modal.addEventListener('click', (e) => {
if (e.target === modal) {
closeBulkImportModal();
}
});
}
}
/**
* Load recipients for the current campaign
*/
async function loadRecipients(campaignSlug) {
console.log('loadRecipients() called with campaignSlug:', campaignSlug);
console.log('currentCampaignSlug:', currentCampaignSlug);
// Use provided slug or fall back to currentCampaignSlug
const slug = campaignSlug || currentCampaignSlug;
if (!slug) {
console.error('No campaign slug available to load recipients');
showMessage('No campaign selected', 'error');
return [];
}
try {
console.log('Fetching recipients from:', `/campaigns/${slug}/custom-recipients`);
const data = await window.apiClient.get(`/campaigns/${slug}/custom-recipients`);
console.log('Recipients data received:', data);
recipients = data.recipients || [];
console.log('Loaded recipients count:', recipients.length);
displayRecipients();
return recipients;
} catch (error) {
console.error('Error loading recipients:', error);
showMessage('Failed to load recipients: ' + error.message, 'error');
return [];
}
}
/**
* Display recipients list
*/
function displayRecipients() {
console.log('displayRecipients() called, recipients count:', recipients.length);
const container = document.getElementById('recipients-list');
console.log('Recipients container found:', container);
if (!container) {
console.error('Recipients list container not found!');
return;
}
if (recipients.length === 0) {
container.innerHTML = '<div class="empty-state">No custom recipients added yet. Use the form above to add recipients.</div>';
console.log('Displayed empty state');
return;
}
console.log('Rendering', recipients.length, 'recipients');
container.innerHTML = recipients.map(recipient => `
<div class="recipient-card" data-id="${recipient.id}">
<div class="recipient-info">
<div class="recipient-name">${escapeHtml(recipient.recipient_name)}</div>
<div class="recipient-email">${escapeHtml(recipient.recipient_email)}</div>
${recipient.recipient_title ? `<div class="recipient-meta">${escapeHtml(recipient.recipient_title)}</div>` : ''}
${recipient.recipient_organization ? `<div class="recipient-meta">${escapeHtml(recipient.recipient_organization)}</div>` : ''}
${recipient.notes ? `<div class="recipient-meta"><em>${escapeHtml(recipient.notes)}</em></div>` : ''}
</div>
<div class="recipient-actions">
<button class="btn-icon edit-recipient" data-id="${recipient.id}" title="Edit recipient">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M12.854.146a.5.5 0 0 0-.707 0L10.5 1.793 14.207 5.5l1.647-1.646a.5.5 0 0 0 0-.708l-3-3zm.646 6.061L9.793 2.5 3.293 9H3.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.207l6.5-6.5zm-7.468 7.468A.5.5 0 0 1 6 13.5V13h-.5a.5.5 0 0 1-.5-.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.5-.5V10h-.5a.499.499 0 0 1-.175-.032l-.179.178a.5.5 0 0 0-.11.168l-2 5a.5.5 0 0 0 .65.65l5-2a.5.5 0 0 0 .168-.11l.178-.178z"/>
</svg>
</button>
<button class="btn-icon delete-recipient" data-id="${recipient.id}" title="Delete recipient">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
</svg>
</button>
</div>
</div>
`).join('');
// Add event listeners to edit and delete buttons
container.querySelectorAll('.edit-recipient').forEach(btn => {
btn.addEventListener('click', (e) => {
const id = e.currentTarget.dataset.id;
handleEditRecipient(id);
});
});
container.querySelectorAll('.delete-recipient').forEach(btn => {
btn.addEventListener('click', (e) => {
const id = e.currentTarget.dataset.id;
handleDeleteRecipient(id);
});
});
}
/**
* Handle add recipient form submission
*/
async function handleAddRecipient(e) {
console.log('handleAddRecipient called, event:', e);
e.preventDefault();
console.log('Form submission prevented, currentCampaignSlug:', currentCampaignSlug);
const formData = {
recipient_name: document.getElementById('recipient-name').value.trim(),
recipient_email: document.getElementById('recipient-email').value.trim(),
recipient_title: document.getElementById('recipient-title').value.trim(),
recipient_organization: document.getElementById('recipient-organization').value.trim(),
notes: document.getElementById('recipient-notes').value.trim()
};
console.log('Form data collected:', formData);
// Validate email
if (!validateEmail(formData.recipient_email)) {
console.error('Email validation failed');
showMessage('Please enter a valid email address', 'error');
return;
}
console.log('Email validation passed');
try {
const url = `/campaigns/${currentCampaignSlug}/custom-recipients`;
console.log('Making POST request to:', url);
const data = await window.apiClient.post(url, formData);
console.log('Response data:', data);
showMessage('Recipient added successfully', 'success');
e.target.reset();
await loadRecipients(currentCampaignSlug);
} catch (error) {
console.error('Error adding recipient:', error);
showMessage('Failed to add recipient: ' + error.message, 'error');
}
}
/**
* Handle edit recipient
*/
async function handleEditRecipient(recipientId) {
const recipient = recipients.find(r => r.id == recipientId);
if (!recipient) return;
// Populate form with recipient data
document.getElementById('recipient-name').value = recipient.recipient_name || '';
document.getElementById('recipient-email').value = recipient.recipient_email || '';
document.getElementById('recipient-title').value = recipient.recipient_title || '';
document.getElementById('recipient-organization').value = recipient.recipient_organization || '';
document.getElementById('recipient-notes').value = recipient.notes || '';
// Change form behavior to update instead of create
const form = document.getElementById('add-recipient-form');
const submitBtn = form.querySelector('button[type="submit"]');
// Store the recipient ID for update
form.dataset.editingId = recipientId;
submitBtn.textContent = 'Update Recipient';
// Add cancel button if it doesn't exist
let cancelBtn = form.querySelector('.cancel-edit-btn');
if (!cancelBtn) {
cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
cancelBtn.className = 'btn secondary cancel-edit-btn';
cancelBtn.textContent = 'Cancel';
cancelBtn.addEventListener('click', cancelEdit);
submitBtn.parentNode.insertBefore(cancelBtn, submitBtn.nextSibling);
}
// Update form submit handler
form.removeEventListener('submit', handleAddRecipient);
form.addEventListener('submit', handleUpdateRecipient);
// Scroll to form
form.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
/**
* Handle update recipient
*/
async function handleUpdateRecipient(e) {
e.preventDefault();
const form = e.target;
const recipientId = form.dataset.editingId;
const formData = {
recipient_name: document.getElementById('recipient-name').value.trim(),
recipient_email: document.getElementById('recipient-email').value.trim(),
recipient_title: document.getElementById('recipient-title').value.trim(),
recipient_organization: document.getElementById('recipient-organization').value.trim(),
notes: document.getElementById('recipient-notes').value.trim()
};
// Validate email
if (!validateEmail(formData.recipient_email)) {
showMessage('Please enter a valid email address', 'error');
return;
}
try {
const data = await window.apiClient.put(`/campaigns/${currentCampaignSlug}/custom-recipients/${recipientId}`, formData);
showMessage('Recipient updated successfully', 'success');
cancelEdit();
await loadRecipients(currentCampaignSlug);
} catch (error) {
console.error('Error updating recipient:', error);
showMessage('Failed to update recipient: ' + error.message, 'error');
}
}
/**
* Cancel edit mode
*/
function cancelEdit() {
const form = document.getElementById('add-recipient-form');
const submitBtn = form.querySelector('button[type="submit"]');
const cancelBtn = form.querySelector('.cancel-edit-btn');
// Reset form
form.reset();
delete form.dataset.editingId;
submitBtn.textContent = 'Add Recipient';
// Remove cancel button
if (cancelBtn) {
cancelBtn.remove();
}
// Restore original submit handler
form.removeEventListener('submit', handleUpdateRecipient);
form.addEventListener('submit', handleAddRecipient);
}
/**
* Handle delete recipient
*/
async function handleDeleteRecipient(recipientId) {
if (!confirm('Are you sure you want to delete this recipient?')) {
return;
}
try {
const data = await window.apiClient.delete(`/campaigns/${currentCampaignSlug}/custom-recipients/${recipientId}`);
showMessage('Recipient deleted successfully', 'success');
await loadRecipients(currentCampaignSlug);
} catch (error) {
console.error('Error deleting recipient:', error);
showMessage('Failed to delete recipient: ' + error.message, 'error');
}
}
/**
* Handle clear all recipients
*/
async function handleClearAll() {
if (!confirm('Are you sure you want to delete ALL custom recipients for this campaign? This cannot be undone.')) {
return;
}
try {
const data = await window.apiClient.delete(`/campaigns/${currentCampaignSlug}/custom-recipients`);
showMessage(`Successfully deleted ${data.deletedCount} recipient(s)`, 'success');
await loadRecipients(currentCampaignSlug);
} catch (error) {
console.error('Error deleting all recipients:', error);
showMessage('Failed to delete recipients: ' + error.message, 'error');
}
}
/**
* Open bulk import modal
*/
function openBulkImportModal() {
const modal = document.getElementById('bulk-import-modal');
if (modal) {
modal.style.display = 'block';
// Clear previous results
document.getElementById('import-results').innerHTML = '';
document.getElementById('csv-file-input').value = '';
document.getElementById('csv-paste-input').value = '';
}
}
/**
* Close bulk import modal
*/
function closeBulkImportModal() {
const modal = document.getElementById('bulk-import-modal');
if (modal) {
modal.style.display = 'none';
}
}
/**
* Handle bulk import
*/
async function handleBulkImport() {
const fileInput = document.getElementById('csv-file-input');
const pasteInput = document.getElementById('csv-paste-input');
const resultsDiv = document.getElementById('import-results');
let csvText = '';
// Check file input first
if (fileInput.files.length > 0) {
const file = fileInput.files[0];
csvText = await readFileAsText(file);
} else if (pasteInput.value.trim()) {
csvText = pasteInput.value.trim();
} else {
resultsDiv.innerHTML = '<div class="error">Please select a CSV file or paste CSV data</div>';
return;
}
// Parse CSV
const parsedRecipients = parseCsv(csvText);
if (parsedRecipients.length === 0) {
resultsDiv.innerHTML = '<div class="error">No valid recipients found in CSV</div>';
return;
}
// Show loading
resultsDiv.innerHTML = '<div class="loading">Importing recipients...</div>';
try {
const data = await window.apiClient.post(`/campaigns/${currentCampaignSlug}/custom-recipients/bulk`, { recipients: parsedRecipients });
if (data.success) {
const { results } = data;
let html = `<div class="success">Successfully imported ${results.success.length} of ${results.total} recipients</div>`;
if (results.failed.length > 0) {
html += '<div class="failed-imports"><strong>Failed imports:</strong><ul>';
results.failed.forEach(failure => {
html += `<li>${escapeHtml(failure.recipient.recipient_name || 'Unknown')} (${escapeHtml(failure.recipient.recipient_email || 'No email')}): ${escapeHtml(failure.error)}</li>`;
});
html += '</ul></div>';
}
resultsDiv.innerHTML = html;
await loadRecipients(currentCampaignSlug);
// Close modal after 3 seconds if all successful
if (results.failed.length === 0) {
setTimeout(closeBulkImportModal, 3000);
}
} else {
throw new Error(data.error || 'Failed to import recipients');
}
} catch (error) {
console.error('Error importing recipients:', error);
resultsDiv.innerHTML = `<div class="error">Failed to import recipients: ${escapeHtml(error.message)}</div>`;
}
}
/**
* Parse CSV text into recipients array
*/
function parseCsv(csvText) {
const lines = csvText.split('\n').filter(line => line.trim());
const recipients = [];
// Skip header row if it exists
const startIndex = lines[0].toLowerCase().includes('recipient_name') ? 1 : 0;
for (let i = startIndex; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
// Simple CSV parsing (doesn't handle quoted commas)
const parts = line.split(',').map(p => p.trim().replace(/^["']|["']$/g, ''));
if (parts.length >= 2) {
recipients.push({
recipient_name: parts[0],
recipient_email: parts[1],
recipient_title: parts[2] || '',
recipient_organization: parts[3] || '',
notes: parts[4] || ''
});
}
}
return recipients;
}
/**
* Read file as text
*/
function readFileAsText(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => resolve(e.target.result);
reader.onerror = (e) => reject(e);
reader.readAsText(file);
});
}
/**
* Validate email format
*/
function validateEmail(email) {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return re.test(email);
}
/**
* Escape HTML to prevent XSS
*/
function escapeHtml(text) {
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, m => map[m]);
}
/**
* Show message to user
*/
function showMessage(message, type = 'info') {
// Try to use existing message display system
if (typeof window.showMessage === 'function') {
window.showMessage(message, type);
} else {
// Fallback to alert
alert(message);
}
}
// Public API
return {
init,
loadRecipients,
displayRecipients
};
})();
// Make available globally
window.CustomRecipients = CustomRecipients;

View File

@ -0,0 +1,465 @@
/**
* Admin Listmonk Management Functions for Influence System
* Handles admin interface for email list synchronization
*/
// Global variables for admin Listmonk functionality
let syncInProgress = false;
let syncProgressInterval = null;
/**
* Initialize Listmonk admin section
*/
async function initListmonkAdmin() {
await refreshListmonkStatus();
await loadListmonkStats();
}
/**
* Refresh the Listmonk sync status display
*/
async function refreshListmonkStatus() {
console.log('🔄 Refreshing Listmonk status...');
try {
const response = await fetch('/api/listmonk/status', {
credentials: 'include'
});
console.log('📡 Status response:', response.status, response.statusText);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const status = await response.json();
console.log('📊 Status data:', status);
updateStatusDisplay(status);
} catch (error) {
console.error('Failed to refresh Listmonk status:', error);
updateStatusDisplay({
enabled: false,
connected: false,
lastError: `Status check failed: ${error.message}`
});
}
}
/**
* Update the status display in the admin panel
*/
function updateStatusDisplay(status) {
console.log('🎨 Updating status display with:', status);
const connectionStatus = document.getElementById('connection-status');
const autosyncStatus = document.getElementById('autosync-status');
const lastError = document.getElementById('last-error');
console.log('🔍 Status elements found:', {
connectionStatus: !!connectionStatus,
autosyncStatus: !!autosyncStatus,
lastError: !!lastError
});
if (connectionStatus) {
if (status.enabled && status.connected) {
connectionStatus.innerHTML = '✅ <span style="color: #27ae60;">Connected</span>';
} else if (status.enabled) {
connectionStatus.innerHTML = '❌ <span style="color: #e74c3c;">Connection Failed</span>';
} else {
connectionStatus.innerHTML = '⭕ <span style="color: #95a5a6;">Disabled</span>';
}
console.log('✅ Connection status updated:', connectionStatus.innerHTML);
}
if (autosyncStatus) {
if (status.enabled) {
autosyncStatus.innerHTML = '✅ <span style="color: #27ae60;">Enabled</span>';
} else {
autosyncStatus.innerHTML = '⭕ <span style="color: #95a5a6;">Disabled</span>';
}
console.log('✅ Auto-sync status updated:', autosyncStatus.innerHTML);
}
if (lastError) {
if (status.lastError) {
lastError.style.display = 'block';
lastError.innerHTML = `<strong>⚠️ Last Error:</strong> ${escapeHtml(status.lastError)}`;
} else {
lastError.style.display = 'none';
}
console.log('✅ Last error updated:', lastError.innerHTML);
}
}
/**
* Load and display Listmonk list statistics
*/
async function loadListmonkStats() {
try {
const response = await fetch('/api/listmonk/stats', {
credentials: 'include'
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
console.log('📊 Stats API response:', data);
if (data.success && data.stats) {
displayListStats(data.stats);
} else {
console.error('Stats API returned unsuccessful response:', data);
displayListStats([]);
}
} catch (error) {
console.error('Failed to load Listmonk stats:', error);
}
}
/**
* Display list statistics in the admin panel
*/
function displayListStats(stats) {
const statsSection = document.getElementById('listmonk-stats-section');
if (!statsSection) return;
console.log('📊 displayListStats called with:', stats, 'Type:', typeof stats);
// Ensure stats is an array
const statsArray = Array.isArray(stats) ? stats : [];
console.log('📊 Stats array after conversion:', statsArray, 'Length:', statsArray.length);
// Clear existing stats
const existingStats = statsSection.querySelector('.stats-list');
if (existingStats) {
existingStats.remove();
}
// Create stats display
const statsList = document.createElement('div');
statsList.className = 'stats-list';
statsList.style.cssText = 'display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1rem;';
if (statsArray.length === 0) {
statsList.innerHTML = '<p style="color: #666; text-align: center; grid-column: 1/-1;">No email lists found or sync is disabled</p>';
} else {
statsArray.forEach(list => {
const statCard = document.createElement('div');
statCard.style.cssText = 'background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 1.5rem; border-radius: 8px; box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);';
statCard.innerHTML = `
<h4 style="margin: 0 0 0.5rem 0; font-size: 0.9rem; opacity: 0.9;">${escapeHtml(list.name)}</h4>
<p style="margin: 0; font-size: 2rem; font-weight: bold;">${list.subscriberCount || 0}</p>
<p style="margin: 0.25rem 0 0 0; font-size: 0.85rem; opacity: 0.9;">subscribers</p>
`;
statsList.appendChild(statCard);
});
}
statsSection.appendChild(statsList);
}
/**
* Sync data to Listmonk
* @param {string} type - 'participants', 'recipients', or 'all'
*/
async function syncToListmonk(type) {
if (syncInProgress) {
showNotification('Sync already in progress', 'warning');
return;
}
syncInProgress = true;
const progressSection = document.getElementById('sync-progress');
const resultsDiv = document.getElementById('sync-results');
const progressBar = document.getElementById('sync-progress-bar');
// Show progress section
if (progressSection) {
progressSection.style.display = 'block';
}
// Reset progress
if (progressBar) {
progressBar.style.width = '0%';
progressBar.textContent = '0%';
}
if (resultsDiv) {
resultsDiv.innerHTML = '<div class="sync-result info"><strong>⏳ Starting sync...</strong></div>';
}
// Disable sync buttons
const buttons = document.querySelectorAll('.sync-buttons .btn');
buttons.forEach(btn => {
btn.disabled = true;
btn.style.opacity = '0.6';
});
// Simulate progress
let progress = 0;
const progressInterval = setInterval(() => {
if (progress < 90) {
progress += Math.random() * 10;
if (progressBar) {
progressBar.style.width = `${Math.min(progress, 90)}%`;
progressBar.textContent = `${Math.floor(Math.min(progress, 90))}%`;
}
}
}, 200);
try {
let endpoint = '/api/listmonk/sync/';
let syncName = '';
switch(type) {
case 'participants':
endpoint += 'participants';
syncName = 'Campaign Participants';
break;
case 'recipients':
endpoint += 'recipients';
syncName = 'Custom Recipients';
break;
case 'all':
endpoint += 'all';
syncName = 'All Data';
break;
default:
throw new Error('Invalid sync type');
}
const response = await fetch(endpoint, {
method: 'POST',
credentials: 'include'
});
clearInterval(progressInterval);
if (progressBar) {
progressBar.style.width = '100%';
progressBar.textContent = '100%';
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
displaySyncResults(result, resultsDiv);
// Refresh stats after successful sync
await loadListmonkStats();
} catch (error) {
clearInterval(progressInterval);
console.error('Sync failed:', error);
if (resultsDiv) {
resultsDiv.innerHTML = `
<div class="sync-result error" style="background: #f8d7da; color: #721c24; padding: 1rem; border-radius: 8px; border-left: 4px solid #e74c3c;">
<strong> Sync Failed</strong>
<p style="margin: 0.5rem 0 0 0;">${escapeHtml(error.message)}</p>
</div>
`;
}
} finally {
syncInProgress = false;
// Re-enable sync buttons
buttons.forEach(btn => {
btn.disabled = false;
btn.style.opacity = '1';
});
}
}
/**
* Display sync results in the admin panel
*/
function displaySyncResults(result, resultsDiv) {
if (!resultsDiv) return;
let html = '';
if (result.success) {
html += `<div class="sync-result success" style="background: #d4edda; color: #155724; padding: 1rem; border-radius: 8px; border-left: 4px solid #27ae60; margin-bottom: 1rem;">
<strong> ${escapeHtml(result.message || 'Sync completed successfully')}</strong>
</div>`;
if (result.results) {
// Handle different result structures
if (result.results.participants || result.results.customRecipients) {
// Multi-type sync (all)
if (result.results.participants) {
html += formatSyncResults('Campaign Participants', result.results.participants);
}
if (result.results.customRecipients) {
html += formatSyncResults('Custom Recipients', result.results.customRecipients);
}
} else {
// Single type sync
html += formatSyncResults('Results', result.results);
}
}
} else {
html += `<div class="sync-result error" style="background: #f8d7da; color: #721c24; padding: 1rem; border-radius: 8px; border-left: 4px solid #e74c3c;">
<strong> Sync Failed</strong>
<p style="margin: 0.5rem 0 0 0;">${escapeHtml(result.error || result.message || 'Unknown error')}</p>
</div>`;
}
resultsDiv.innerHTML = html;
}
/**
* Format sync results for display
*/
function formatSyncResults(type, results) {
let html = `<div class="sync-result info" style="background: #e3f2fd; padding: 1rem; border-radius: 8px; border-left: 4px solid #2196f3; margin-bottom: 0.5rem;">
<strong>📊 ${escapeHtml(type)}:</strong>
<span style="color: #27ae60; font-weight: bold;">${results.success} succeeded</span>,
<span style="color: #e74c3c; font-weight: bold;">${results.failed} failed</span>
(${results.total} total)
</div>`;
// Show errors if any
if (results.errors && results.errors.length > 0) {
const maxErrors = 5; // Show max 5 errors
const errorCount = results.errors.length;
html += `<div class="sync-result warning" style="background: #fff3cd; color: #856404; padding: 1rem; border-radius: 8px; border-left: 4px solid #f39c12; margin-bottom: 0.5rem;">
<strong> Errors (showing ${Math.min(errorCount, maxErrors)} of ${errorCount}):</strong>
<ul style="margin: 0.5rem 0 0 0; padding-left: 1.5rem;">`;
results.errors.slice(0, maxErrors).forEach(error => {
html += `<li style="margin: 0.25rem 0;">${escapeHtml(error.email || 'Unknown')}: ${escapeHtml(error.error || 'Unknown error')}</li>`;
});
html += '</ul></div>';
}
return html;
}
/**
* Test Listmonk connection
*/
async function testListmonkConnection() {
try {
const response = await fetch('/api/listmonk/test-connection', {
method: 'POST',
credentials: 'include'
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
if (result.success) {
showNotification('✅ Listmonk connection successful!', 'success');
await refreshListmonkStatus();
} else {
showNotification(`❌ Connection failed: ${result.message}`, 'error');
}
} catch (error) {
console.error('Connection test failed:', error);
showNotification(`❌ Connection test failed: ${error.message}`, 'error');
}
}
/**
* Reinitialize Listmonk lists
*/
async function reinitializeListmonk() {
if (!confirm('⚠️ This will recreate all email lists. Are you sure?')) {
return;
}
try {
const response = await fetch('/api/listmonk/reinitialize', {
method: 'POST',
credentials: 'include'
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
if (result.success) {
showNotification('✅ Listmonk lists reinitialized successfully!', 'success');
await refreshListmonkStatus();
await loadListmonkStats();
} else {
showNotification(`❌ Reinitialization failed: ${result.message}`, 'error');
}
} catch (error) {
console.error('Reinitialization failed:', error);
showNotification(`❌ Reinitialization failed: ${error.message}`, 'error');
}
}
/**
* Utility function to escape HTML
*/
function escapeHtml(text) {
if (typeof text !== 'string') return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Show notification message
*/
function showNotification(message, type = 'info') {
const messageContainer = document.getElementById('message-container');
if (!messageContainer) {
// Fallback to alert if container not found
alert(message);
return;
}
const alertClass = type === 'success' ? 'message-success' :
type === 'error' ? 'message-error' :
'message-info';
messageContainer.className = alertClass;
messageContainer.textContent = message;
messageContainer.classList.remove('hidden');
// Auto-hide after 5 seconds
setTimeout(() => {
messageContainer.classList.add('hidden');
}, 5000);
}
// Initialize Listmonk admin when tab is activated
document.addEventListener('DOMContentLoaded', () => {
// Watch for tab changes
const listmonkTab = document.querySelector('[data-tab="listmonk"]');
if (listmonkTab) {
listmonkTab.addEventListener('click', () => {
// Initialize on first load
if (!listmonkTab.dataset.initialized) {
initListmonkAdmin();
listmonkTab.dataset.initialized = 'true';
}
});
}
});
// Export functions for global use
window.syncToListmonk = syncToListmonk;
window.refreshListmonkStatus = refreshListmonkStatus;
window.testListmonkConnection = testListmonkConnection;
window.reinitializeListmonk = reinitializeListmonk;
window.initListmonkAdmin = initListmonkAdmin;

View File

@ -257,8 +257,7 @@ function determineGovernmentLevel(electedOffice) {
// Load response statistics and campaign details // Load response statistics and campaign details
async function loadResponseStats() { async function loadResponseStats() {
try { try {
const response = await fetch(`/api/campaigns/${currentCampaignSlug}/response-stats`); const data = await window.apiClient.get(`/campaigns/${currentCampaignSlug}/response-stats`);
const data = await response.json();
if (data.success) { if (data.success) {
// Store campaign data // Store campaign data
@ -601,8 +600,7 @@ async function loadResponses(reset = false) {
limit: LIMIT limit: LIMIT
}); });
const response = await fetch(`/api/campaigns/${currentCampaignSlug}/responses?${params}`); const data = await window.apiClient.get(`/campaigns/${currentCampaignSlug}/responses?${params}`);
const data = await response.json();
showLoading(false); showLoading(false);
@ -756,17 +754,12 @@ function createResponseCard(response) {
// Toggle upvote // Toggle upvote
async function toggleUpvote(responseId, button) { async function toggleUpvote(responseId, button) {
const isUpvoted = button.classList.contains('upvoted'); const isUpvoted = button.classList.contains('upvoted');
const url = `/api/responses/${responseId}/upvote`; const url = `/responses/${responseId}/upvote`;
try { try {
const response = await fetch(url, { const data = isUpvoted
method: isUpvoted ? 'DELETE' : 'POST', ? await window.apiClient.delete(url)
headers: { : await window.apiClient.post(url, {});
'Content-Type': 'application/json'
}
});
const data = await response.json();
if (data.success) { if (data.success) {
// Update button state // Update button state
@ -837,12 +830,7 @@ async function handleSubmitResponse(e) {
const repEmail = document.getElementById('representative-email').value; const repEmail = document.getElementById('representative-email').value;
try { try {
const response = await fetch(`/api/campaigns/${currentCampaignSlug}/responses`, { const data = await window.apiClient.postFormData(`/campaigns/${currentCampaignSlug}/responses`, formData);
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) { if (data.success) {
let message = data.message || 'Response submitted successfully! It will appear after moderation.'; let message = data.message || 'Response submitted successfully! It will appear after moderation.';
@ -930,16 +918,9 @@ async function handleVerifyClick(responseId, verificationToken, representativeEm
// Make request to resend verification email // Make request to resend verification email
try { try {
const response = await fetch(`/api/responses/${responseId}/resend-verification`, { const data = await window.apiClient.post(`/responses/${responseId}/resend-verification`, {});
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const data = await response.json(); if (data.success) {
if (response.ok && data.success) {
showSuccess( showSuccess(
'Verification email sent successfully!\n\n' + 'Verification email sent successfully!\n\n' +
`An email has been sent to ${representativeEmail} with a verification link.\n\n` + `An email has been sent to ${representativeEmail} with a verification link.\n\n` +

View File

@ -4,6 +4,7 @@ const { body, param, validationResult } = require('express-validator');
const representativesController = require('../controllers/representatives'); const representativesController = require('../controllers/representatives');
const emailsController = require('../controllers/emails'); const emailsController = require('../controllers/emails');
const campaignsController = require('../controllers/campaigns'); const campaignsController = require('../controllers/campaigns');
const customRecipientsController = require('../controllers/customRecipients');
const responsesController = require('../controllers/responses'); const responsesController = require('../controllers/responses');
const rateLimiter = require('../utils/rate-limiter'); const rateLimiter = require('../utils/rate-limiter');
const { requireAdmin, requireAuth, requireNonTemp, optionalAuth } = require('../middleware/auth'); const { requireAdmin, requireAuth, requireNonTemp, optionalAuth } = require('../middleware/auth');
@ -12,6 +13,9 @@ const upload = require('../middleware/upload');
// Import user routes // Import user routes
const userRoutes = require('./users'); const userRoutes = require('./users');
// Import Listmonk routes
const listmonkRoutes = require('./listmonk');
// Validation middleware // Validation middleware
const handleValidationErrors = (req, res, next) => { const handleValidationErrors = (req, res, next) => {
const errors = validationResult(req); const errors = validationResult(req);
@ -223,9 +227,66 @@ router.post(
representativesController.trackCall representativesController.trackCall
); );
// Custom Recipients Routes
router.get(
'/campaigns/:slug/custom-recipients',
requireNonTemp,
rateLimiter.general,
customRecipientsController.getRecipientsByCampaign
);
router.post(
'/campaigns/:slug/custom-recipients',
requireNonTemp,
rateLimiter.general,
[
body('recipient_name').notEmpty().withMessage('Recipient name is required'),
body('recipient_email').isEmail().withMessage('Valid recipient email is required')
],
handleValidationErrors,
customRecipientsController.createRecipient
);
router.post(
'/campaigns/:slug/custom-recipients/bulk',
requireNonTemp,
rateLimiter.general,
[
body('recipients').isArray().withMessage('Recipients must be an array'),
body('recipients.*.recipient_name').notEmpty().withMessage('Each recipient must have a name'),
body('recipients.*.recipient_email').isEmail().withMessage('Each recipient must have a valid email')
],
handleValidationErrors,
customRecipientsController.bulkCreateRecipients
);
router.put(
'/campaigns/:slug/custom-recipients/:id',
requireNonTemp,
rateLimiter.general,
customRecipientsController.updateRecipient
);
router.delete(
'/campaigns/:slug/custom-recipients/:id',
requireNonTemp,
rateLimiter.general,
customRecipientsController.deleteRecipient
);
router.delete(
'/campaigns/:slug/custom-recipients',
requireNonTemp,
rateLimiter.general,
customRecipientsController.deleteAllRecipients
);
// User management routes (admin only) // User management routes (admin only)
router.use('/admin/users', userRoutes); router.use('/admin/users', userRoutes);
// Listmonk email sync routes (admin only)
router.use('/listmonk', listmonkRoutes);
// Response Wall Routes // Response Wall Routes
router.get('/campaigns/:slug/responses', rateLimiter.general, responsesController.getCampaignResponses); router.get('/campaigns/:slug/responses', rateLimiter.general, responsesController.getCampaignResponses);
router.get('/campaigns/:slug/response-stats', rateLimiter.general, responsesController.getResponseStats); router.get('/campaigns/:slug/response-stats', rateLimiter.general, responsesController.getResponseStats);

View File

@ -0,0 +1,26 @@
const express = require('express');
const router = express.Router();
const listmonkController = require('../controllers/listmonkController');
const { requireAdmin } = require('../middleware/auth');
// All Listmonk routes require admin authentication
router.use(requireAdmin);
// Get sync status
router.get('/status', listmonkController.getSyncStatus);
// Get list statistics
router.get('/stats', listmonkController.getListStats);
// Test connection
router.post('/test-connection', listmonkController.testConnection);
// Sync endpoints
router.post('/sync/participants', listmonkController.syncCampaignParticipants);
router.post('/sync/recipients', listmonkController.syncCustomRecipients);
router.post('/sync/all', listmonkController.syncAll);
// Reinitialize lists
router.post('/reinitialize', listmonkController.reinitializeLists);
module.exports = router;

View File

@ -10,7 +10,7 @@ require('dotenv').config();
const logger = require('./utils/logger'); const logger = require('./utils/logger');
const metrics = require('./utils/metrics'); const metrics = require('./utils/metrics');
const healthCheck = require('./utils/health-check'); const healthCheck = require('./utils/health-check');
const { conditionalCsrfProtection, getCsrfToken } = require('./middleware/csrf'); const { conditionalCsrfProtection, getCsrfToken, csrfProtection } = require('./middleware/csrf');
const apiRoutes = require('./routes/api'); const apiRoutes = require('./routes/api');
const authRoutes = require('./routes/auth'); const authRoutes = require('./routes/auth');
@ -48,7 +48,11 @@ app.use(helmet({
})); }));
// Middleware // Middleware
app.use(cors()); // CORS configuration - Allow credentials for cookie-based CSRF
app.use(cors({
origin: true, // Allow requests from same origin
credentials: true // Allow cookies to be sent
}));
app.use(express.json({ limit: '10mb' })); app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' })); app.use(express.urlencoded({ extended: true, limit: '10mb' }));
app.use(cookieParser()); app.use(cookieParser());
@ -130,13 +134,6 @@ app.get('/api/metrics', async (req, res) => {
} }
}); });
// CSRF token endpoint
app.get('/api/csrf-token', getCsrfToken);
// Routes
app.use('/api/auth', authRoutes);
app.use('/api', apiRoutes);
// Config endpoint - expose APP_URL to client // Config endpoint - expose APP_URL to client
app.get('/api/config', (req, res) => { app.get('/api/config', (req, res) => {
res.json({ res.json({
@ -144,6 +141,20 @@ app.get('/api/config', (req, res) => {
}); });
}); });
// CSRF token endpoint - Needs CSRF middleware to generate token
app.get('/api/csrf-token', csrfProtection, (req, res) => {
logger.info('CSRF token endpoint hit');
getCsrfToken(req, res);
});
logger.info('CSRF token endpoint registered at /api/csrf-token');
// Auth routes must come before generic API routes
app.use('/api/auth', authRoutes);
// Generic API routes (catches all /api/*)
app.use('/api', apiRoutes);
// Serve the main page // Serve the main page
app.get('/', (req, res) => { app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html')); res.sendFile(path.join(__dirname, 'public', 'index.html'));

View File

@ -0,0 +1,832 @@
const axios = require('axios');
const logger = require('../utils/logger');
class ListmonkService {
constructor() {
this.baseURL = process.env.LISTMONK_API_URL || 'http://listmonk:9000/api';
this.username = process.env.LISTMONK_USERNAME;
this.password = process.env.LISTMONK_PASSWORD;
this.lists = {
allCampaigns: null,
activeCampaigns: null,
customRecipients: null,
campaignParticipants: null,
emailLogs: null, // For generic email logs (non-campaign)
campaignLists: {} // Dynamic per-campaign lists
};
// Debug logging for environment variables
console.log('🔍 Listmonk Environment Variables (Influence):');
console.log(` LISTMONK_SYNC_ENABLED: ${process.env.LISTMONK_SYNC_ENABLED}`);
console.log(` LISTMONK_INITIAL_SYNC: ${process.env.LISTMONK_INITIAL_SYNC}`);
console.log(` LISTMONK_API_URL: ${process.env.LISTMONK_API_URL}`);
console.log(` LISTMONK_USERNAME: ${this.username ? 'SET' : 'NOT SET'}`);
console.log(` LISTMONK_PASSWORD: ${this.password ? 'SET' : 'NOT SET'}`);
this.syncEnabled = process.env.LISTMONK_SYNC_ENABLED === 'true';
// Additional validation - disable if credentials are missing
if (this.syncEnabled && (!this.username || !this.password)) {
logger.warn('Listmonk credentials missing - disabling sync');
this.syncEnabled = false;
}
console.log(` Final syncEnabled: ${this.syncEnabled}`);
this.lastError = null;
this.lastErrorTime = null;
}
// Validate and clean email address
validateAndCleanEmail(email) {
if (!email || typeof email !== 'string') {
return { valid: false, cleaned: null, error: 'Email is required' };
}
// Trim whitespace and convert to lowercase
let cleaned = email.trim().toLowerCase();
// Basic email format validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(cleaned)) {
return { valid: false, cleaned: null, error: 'Invalid email format' };
}
// Check for common typos in domain extensions
const commonTypos = {
'.co': '.ca',
'.cA': '.ca',
'.Ca': '.ca',
'.cOM': '.com',
'.coM': '.com',
'.cOm': '.com',
'.neT': '.net',
'.nEt': '.net',
'.ORg': '.org',
'.oRg': '.org'
};
// Fix common domain extension typos
for (const [typo, correction] of Object.entries(commonTypos)) {
if (cleaned.endsWith(typo)) {
const fixedEmail = cleaned.slice(0, -typo.length) + correction;
logger.warn(`Email validation: Fixed typo in ${email} -> ${fixedEmail}`);
cleaned = fixedEmail;
break;
}
}
// Additional validation: check for suspicious patterns
if (cleaned.includes('..') || cleaned.startsWith('.') || cleaned.endsWith('.')) {
return { valid: false, cleaned: null, error: 'Invalid email pattern' };
}
return { valid: true, cleaned, error: null };
}
// Create axios instance with auth
getClient() {
return axios.create({
baseURL: this.baseURL,
auth: {
username: this.username,
password: this.password
},
headers: {
'Content-Type': 'application/json'
},
timeout: 10000 // 10 second timeout
});
}
// Test connection to Listmonk
async checkConnection() {
if (!this.syncEnabled) {
return false;
}
try {
console.log(`🔍 Testing connection to: ${this.baseURL}`);
console.log(`🔍 Using credentials: ${this.username}:${this.password ? 'SET' : 'NOT SET'}`);
const client = this.getClient();
console.log('🔍 Making request to /health endpoint...');
const { data } = await client.get('/health');
console.log('🔍 Response received:', JSON.stringify(data, null, 2));
if (data.data === true) {
logger.info('Listmonk connection successful');
this.lastError = null;
this.lastErrorTime = null;
return true;
}
console.log('🔍 Health check failed - data.data is not true');
return false;
} catch (error) {
console.log('🔍 Connection error details:', error.message);
if (error.response) {
console.log('🔍 Response status:', error.response.status);
console.log('🔍 Response data:', error.response.data);
}
this.lastError = `Listmonk connection failed: ${error.message}`;
this.lastErrorTime = new Date();
logger.error(this.lastError);
return false;
}
}
// Initialize all lists on startup
async initializeLists() {
if (!this.syncEnabled) {
logger.info('Listmonk sync is disabled');
return false;
}
try {
// Check connection first
const connected = await this.checkConnection();
if (!connected) {
throw new Error(`Cannot connect to Listmonk: ${this.lastError || 'Unknown connection error'}`);
}
// Create main campaign lists
this.lists.allCampaigns = await this.ensureList({
name: 'Influence - All Campaigns',
type: 'private',
optin: 'single',
tags: ['influence', 'campaigns', 'automated'],
description: 'All campaign participants from the influence tool'
});
this.lists.activeCampaigns = await this.ensureList({
name: 'Influence - Active Campaigns',
type: 'private',
optin: 'single',
tags: ['influence', 'active', 'automated'],
description: 'Participants in active campaigns only'
});
this.lists.customRecipients = await this.ensureList({
name: 'Influence - Custom Recipients',
type: 'private',
optin: 'single',
tags: ['influence', 'custom-recipients', 'automated'],
description: 'Custom recipients added to campaigns'
});
this.lists.campaignParticipants = await this.ensureList({
name: 'Influence - Campaign Participants',
type: 'private',
optin: 'single',
tags: ['influence', 'participants', 'automated'],
description: 'Users who have participated in sending campaign emails'
});
this.lists.emailLogs = await this.ensureList({
name: 'Influence - Email Logs',
type: 'private',
optin: 'single',
tags: ['influence', 'email-logs', 'automated'],
description: 'All email activity from the public influence service'
});
logger.info('✅ Listmonk main lists initialized successfully');
// Initialize campaign-specific lists for all campaigns
try {
const nocodbService = require('./nocodb');
const campaigns = await nocodbService.getAllCampaigns();
if (campaigns && campaigns.length > 0) {
logger.info(`🔄 Initializing lists for ${campaigns.length} campaigns...`);
for (const campaign of campaigns) {
const slug = campaign['Campaign Slug'];
const title = campaign['Campaign Title'];
const status = campaign['Status'];
if (slug && title) {
try {
const campaignList = await this.ensureCampaignList(slug, title);
if (campaignList) {
logger.info(`📋 Initialized list for campaign: ${title} (${status})`);
}
} catch (error) {
logger.warn(`Failed to initialize list for campaign ${title}:`, error.message);
}
}
}
logger.info(`✅ Campaign lists initialized: ${Object.keys(this.lists.campaignLists).length} lists`);
}
} catch (error) {
logger.warn('Failed to initialize campaign-specific lists:', error.message);
// Don't fail the entire initialization if campaign lists fail
}
return true;
} catch (error) {
this.lastError = `Failed to initialize Listmonk lists: ${error.message}`;
this.lastErrorTime = new Date();
logger.error(this.lastError);
return false;
}
}
// Ensure a list exists, create if not
async ensureList(listConfig) {
try {
const client = this.getClient();
// First, try to find existing list by name
const { data: listsResponse } = await client.get('/lists');
const existingList = listsResponse.data.results.find(list => list.name === listConfig.name);
if (existingList) {
logger.info(`📋 Found existing list: ${listConfig.name}`);
return existingList;
}
// Create new list
const { data: createResponse } = await client.post('/lists', listConfig);
logger.info(`📋 Created new list: ${listConfig.name}`);
return createResponse.data;
} catch (error) {
logger.error(`Failed to ensure list ${listConfig.name}:`, error.message);
throw error;
}
}
// Ensure a list exists for a specific campaign
async ensureCampaignList(campaignSlug, campaignTitle) {
if (!this.syncEnabled) {
return null;
}
// Check if we already have this campaign list cached
if (this.lists.campaignLists[campaignSlug]) {
return this.lists.campaignLists[campaignSlug];
}
try {
const listConfig = {
name: `Campaign: ${campaignTitle}`,
type: 'private',
optin: 'single',
tags: ['influence', 'campaign', campaignSlug, 'automated'],
description: `Participants who sent emails for the "${campaignTitle}" campaign`
};
const list = await this.ensureList(listConfig);
this.lists.campaignLists[campaignSlug] = list;
logger.info(`✅ Campaign list created/found for: ${campaignTitle}`);
return list;
} catch (error) {
logger.error(`Failed to ensure campaign list for ${campaignSlug}:`, error.message);
return null;
}
}
// Sync a campaign participant to Listmonk
async syncCampaignParticipant(emailData, campaignData) {
// Map NocoDB field names (column titles) to properties
// Try User fields first (new Campaign Emails table), fall back to Sender fields (old table)
const userEmail = emailData['User Email'] || emailData['Sender Email'] || emailData.sender_email;
const userName = emailData['User Name'] || emailData['Sender Name'] || emailData.sender_name;
const userPostalCode = emailData['User Postal Code'] || emailData['Postal Code'] || emailData.postal_code;
const createdAt = emailData['CreatedAt'] || emailData.created_at;
const sentTo = emailData['Sent To'] || emailData.sent_to;
const recipientEmail = emailData['Recipient Email'];
const recipientName = emailData['Recipient Name'];
if (!this.syncEnabled || !userEmail) {
return { success: false, error: 'Sync disabled or no email provided' };
}
// Validate and clean the email address
const emailValidation = this.validateAndCleanEmail(userEmail);
if (!emailValidation.valid) {
logger.warn(`Skipping invalid email: ${userEmail} - ${emailValidation.error}`);
return { success: false, error: emailValidation.error };
}
try {
const subscriberLists = [this.lists.allCampaigns.id, this.lists.campaignParticipants.id];
// Add to active campaigns list if campaign is active
const campaignStatus = campaignData?.Status;
if (campaignStatus === 'active') {
subscriberLists.push(this.lists.activeCampaigns.id);
}
// Add to campaign-specific list
const campaignSlug = campaignData?.['Campaign Slug'];
const campaignTitle = campaignData?.['Campaign Title'];
if (campaignSlug && campaignTitle) {
const campaignList = await this.ensureCampaignList(campaignSlug, campaignTitle);
if (campaignList) {
subscriberLists.push(campaignList.id);
logger.info(`📧 Added ${emailValidation.cleaned} to campaign list: ${campaignTitle}`);
}
}
const subscriberData = {
email: emailValidation.cleaned,
name: userName || emailValidation.cleaned,
status: 'enabled',
lists: subscriberLists,
attribs: {
last_campaign: campaignTitle || 'Unknown',
campaign_slug: campaignSlug || null,
last_sent: createdAt ? new Date(createdAt).toISOString() : new Date().toISOString(),
postal_code: userPostalCode || null,
sent_to_representatives: sentTo || null,
last_recipient_email: recipientEmail || null,
last_recipient_name: recipientName || null
}
};
const result = await this.upsertSubscriber(subscriberData);
return { success: true, subscriberId: result.id };
} catch (error) {
logger.error('Failed to sync campaign participant:', error.message);
return { success: false, error: error.message };
}
}
// Sync a custom recipient to Listmonk
async syncCustomRecipient(recipientData, campaignData) {
// Map NocoDB field names (column titles) to properties
const email = recipientData['Recipient Email'];
const name = recipientData['Recipient Name'];
const title = recipientData['Recipient Title'];
const organization = recipientData['Recipient Organization'];
const phone = recipientData['Recipient Phone'];
const createdAt = recipientData['CreatedAt'];
const campaignId = recipientData['Campaign ID'];
if (!this.syncEnabled || !email) {
return { success: false, error: 'Sync disabled or no email provided' };
}
// Validate and clean the email address
const emailValidation = this.validateAndCleanEmail(email);
if (!emailValidation.valid) {
logger.warn(`Skipping invalid recipient email: ${email} - ${emailValidation.error}`);
return { success: false, error: emailValidation.error };
}
try {
const subscriberLists = [this.lists.customRecipients.id];
// Add to campaign-specific list
const campaignSlug = campaignData?.['Campaign Slug'];
const campaignTitle = campaignData?.['Campaign Title'];
if (campaignSlug && campaignTitle) {
const campaignList = await this.ensureCampaignList(campaignSlug, campaignTitle);
if (campaignList) {
subscriberLists.push(campaignList.id);
logger.info(`📧 Added recipient ${emailValidation.cleaned} to campaign list: ${campaignTitle}`);
}
}
const subscriberData = {
email: emailValidation.cleaned,
name: name || emailValidation.cleaned,
status: 'enabled',
lists: subscriberLists,
attribs: {
campaign: campaignTitle || 'Unknown',
campaign_slug: campaignSlug || null,
title: title || null,
organization: organization || null,
phone: phone || null,
added_date: createdAt ? new Date(createdAt).toISOString() : null,
recipient_type: 'custom'
}
};
const result = await this.upsertSubscriber(subscriberData);
return { success: true, subscriberId: result.id };
} catch (error) {
logger.error('Failed to sync custom recipient:', error.message);
return { success: false, error: error.message };
}
}
// Sync an email log entry to Listmonk (generic public emails)
async syncEmailLog(emailData) {
// Map NocoDB field names for the Email Logs table
const senderEmail = emailData['Sender Email'];
const senderName = emailData['Sender Name'];
const recipientEmail = emailData['Recipient Email'];
const postalCode = emailData['Postal Code'];
const createdAt = emailData['CreatedAt'];
const sentAt = emailData['Sent At'];
if (!this.syncEnabled || !senderEmail) {
return { success: false, error: 'Sync disabled or no email provided' };
}
// Validate and clean the email address
const emailValidation = this.validateAndCleanEmail(senderEmail);
if (!emailValidation.valid) {
logger.warn(`Skipping invalid email log: ${senderEmail} - ${emailValidation.error}`);
return { success: false, error: emailValidation.error };
}
try {
const subscriberData = {
email: emailValidation.cleaned,
name: senderName || emailValidation.cleaned,
status: 'enabled',
lists: [this.lists.emailLogs.id],
attribs: {
last_sent: sentAt ? new Date(sentAt).toISOString() : (createdAt ? new Date(createdAt).toISOString() : new Date().toISOString()),
postal_code: postalCode || null,
last_recipient_email: recipientEmail || null,
source: 'email_logs'
}
};
const result = await this.upsertSubscriber(subscriberData);
return { success: true, subscriberId: result.id };
} catch (error) {
logger.error('Failed to sync email log:', error.message);
return { success: false, error: error.message };
}
}
// Upsert subscriber (create or update)
async upsertSubscriber(subscriberData) {
try {
const client = this.getClient();
// Try to find existing subscriber by email
const { data: searchResponse } = await client.get('/subscribers', {
params: { query: `subscribers.email = '${subscriberData.email}'` }
});
if (searchResponse.data.results && searchResponse.data.results.length > 0) {
// Update existing subscriber
const existingSubscriber = searchResponse.data.results[0];
const subscriberId = existingSubscriber.id;
// Merge lists (don't remove existing ones)
const existingLists = existingSubscriber.lists.map(l => l.id);
const newLists = [...new Set([...existingLists, ...subscriberData.lists])];
// Merge attributes
const mergedAttribs = {
...existingSubscriber.attribs,
...subscriberData.attribs
};
const updateData = {
...subscriberData,
lists: newLists,
attribs: mergedAttribs
};
const { data: updateResponse } = await client.put(`/subscribers/${subscriberId}`, updateData);
logger.info(`Updated subscriber: ${subscriberData.email}`);
return updateResponse.data;
} else {
// Create new subscriber
const { data: createResponse } = await client.post('/subscribers', subscriberData);
logger.info(`Created new subscriber: ${subscriberData.email}`);
return createResponse.data;
}
} catch (error) {
logger.error(`Failed to upsert subscriber ${subscriberData.email}:`, error.message);
throw error;
}
}
// Bulk sync campaign participants
async bulkSyncCampaignParticipants(emails, campaigns) {
if (!this.syncEnabled) {
return { total: 0, success: 0, failed: 0, errors: [] };
}
const results = {
total: emails.length,
success: 0,
failed: 0,
errors: []
};
// Create a map of campaign IDs to campaign data for quick lookup
const campaignMap = {};
if (campaigns && Array.isArray(campaigns)) {
console.log(`🔍 Building campaign map from ${campaigns.length} campaigns`);
campaigns.forEach((campaign, index) => {
// Show keys for first campaign to debug
if (index === 0) {
console.log('🔍 First campaign keys:', Object.keys(campaign));
}
// NocoDB returns 'ID' (all caps) as the system field for record ID
// This is what the 'Campaign ID' link field in emails table references
const id = campaign.ID || campaign.Id || campaign.id;
if (id) {
campaignMap[id] = campaign;
// Also map by slug for fallback lookup
const slug = campaign['Campaign Slug'];
if (slug) {
campaignMap[slug] = campaign;
}
const title = campaign['Campaign Title'];
console.log(`🔍 Mapped campaign ID ${id} and slug ${slug}: ${title}`);
} else {
console.log('⚠️ Campaign has no ID field! Keys:', Object.keys(campaign));
}
});
console.log(`🔍 Campaign map has ${Object.keys(campaignMap).length} entries`);
} else {
console.log('⚠️ No campaigns provided for mapping!');
}
for (const email of emails) {
try {
// Try to find campaign data by Campaign ID (link field) or Campaign Slug (text field)
const campaignId = email['Campaign ID'];
const campaignSlug = email['Campaign Slug'];
const campaignData = campaignId ? campaignMap[campaignId] : (campaignSlug ? campaignMap[campaignSlug] : null);
// Debug first email
if (emails.indexOf(email) === 0) {
console.log('🔍 First email keys:', Object.keys(email));
console.log('🔍 First email Campaign ID field:', email['Campaign ID']);
console.log('🔍 First email Campaign Slug field:', email['Campaign Slug']);
}
if (!campaignData && (campaignId || campaignSlug)) {
console.log(`⚠️ Campaign not found - ID: ${campaignId}, Slug: ${campaignSlug}. Available IDs:`, Object.keys(campaignMap).slice(0, 10));
}
const result = await this.syncCampaignParticipant(email, campaignData);
if (result.success) {
results.success++;
} else {
results.failed++;
const emailAddr = email['Sender Email'] || email.sender_email || 'unknown';
results.errors.push({
email: emailAddr,
error: result.error
});
}
} catch (error) {
results.failed++;
const emailAddr = email['Sender Email'] || email.sender_email || 'unknown';
results.errors.push({
email: emailAddr,
error: error.message
});
}
}
logger.info(`Bulk sync completed: ${results.success} succeeded, ${results.failed} failed`);
return results;
}
// Bulk sync custom recipients
async bulkSyncCustomRecipients(recipients, campaigns) {
if (!this.syncEnabled) {
return { total: 0, success: 0, failed: 0, errors: [] };
}
const results = {
total: recipients.length,
success: 0,
failed: 0,
errors: []
};
// Create a map of campaign IDs to campaign data for quick lookup
const campaignMap = {};
if (campaigns && Array.isArray(campaigns)) {
campaigns.forEach(campaign => {
// NocoDB returns 'ID' (all caps) as the system field for record ID
const id = campaign.ID || campaign.Id || campaign.id;
if (id) {
campaignMap[id] = campaign;
// Also map by slug for fallback lookup
const slug = campaign['Campaign Slug'];
if (slug) {
campaignMap[slug] = campaign;
}
}
});
}
for (const recipient of recipients) {
try {
// Try to find campaign data by Campaign ID or Campaign Slug
const campaignId = recipient['Campaign ID'];
const campaignSlug = recipient['Campaign Slug'];
const campaignData = campaignId ? campaignMap[campaignId] : (campaignSlug ? campaignMap[campaignSlug] : null);
const result = await this.syncCustomRecipient(recipient, campaignData);
if (result.success) {
results.success++;
} else {
results.failed++;
const emailAddr = recipient['Recipient Email'] || 'unknown';
results.errors.push({
email: emailAddr,
error: result.error
});
}
} catch (error) {
results.failed++;
const emailAddr = recipient['Recipient Email'] || 'unknown';
results.errors.push({
email: emailAddr,
error: error.message
});
}
}
logger.info(`Bulk sync custom recipients completed: ${results.success} succeeded, ${results.failed} failed`);
return results;
}
// Bulk sync email logs
async bulkSyncEmailLogs(emailLogs) {
if (!this.syncEnabled) {
return { total: 0, success: 0, failed: 0, errors: [] };
}
const results = {
total: emailLogs.length,
success: 0,
failed: 0,
errors: []
};
for (const emailLog of emailLogs) {
try {
const result = await this.syncEmailLog(emailLog);
if (result.success) {
results.success++;
} else {
results.failed++;
const emailAddr = emailLog['Sender Email'] || 'unknown';
results.errors.push({
email: emailAddr,
error: result.error
});
}
} catch (error) {
results.failed++;
const emailAddr = emailLog['Sender Email'] || 'unknown';
results.errors.push({
email: emailAddr,
error: error.message
});
}
}
logger.info(`Bulk sync email logs completed: ${results.success} succeeded, ${results.failed} failed`);
return results;
}
// Get list statistics
async getListStats() {
if (!this.syncEnabled) {
return null;
}
try {
const client = this.getClient();
const { data: listsResponse } = await client.get('/lists');
const stats = {};
const influenceLists = listsResponse.data.results.filter(list =>
list.tags && list.tags.includes('influence')
);
for (const list of influenceLists) {
stats[list.name] = {
name: list.name,
subscriber_count: list.subscriber_count || 0,
id: list.id
};
}
return stats;
} catch (error) {
logger.error('Failed to get list stats:', error.message);
return null;
}
}
// Get sync status
getSyncStatus() {
return {
enabled: this.syncEnabled,
connected: this.lastError === null,
lastError: this.lastError,
lastErrorTime: this.lastErrorTime,
listsInitialized: Object.values(this.lists).every(list => list !== null)
};
}
}
// Create singleton instance
const listmonkService = new ListmonkService();
// Initialize lists on startup if enabled
if (listmonkService.syncEnabled) {
listmonkService.initializeLists()
.then(async () => {
logger.info('✅ Listmonk service initialized successfully');
// Optional initial sync (only if explicitly enabled)
if (process.env.LISTMONK_INITIAL_SYNC === 'true') {
logger.info('🔄 Performing initial Listmonk sync for influence system...');
// Use setTimeout to delay initial sync to let app fully start
setTimeout(async () => {
try {
const nocodbService = require('./nocodb');
// Sync existing campaign participants
try {
// Use campaignEmails table (not emails) to get proper User Email/Name fields
const emailsData = await nocodbService.getAll(nocodbService.tableIds.campaignEmails);
const emails = emailsData?.list || [];
console.log('🔍 Initial sync - fetched campaign emails:', emails?.length || 0);
if (emails && emails.length > 0) {
console.log('🔍 First email full data:', JSON.stringify(emails[0], null, 2));
}
if (emails && emails.length > 0) {
const campaigns = await nocodbService.getAllCampaigns();
console.log('🔍 Campaigns fetched:', campaigns?.length || 0);
if (campaigns && campaigns.length > 0) {
console.log('🔍 First campaign full data:', JSON.stringify(campaigns[0], null, 2));
}
const emailResults = await listmonkService.bulkSyncCampaignParticipants(emails, campaigns);
logger.info(`📧 Initial campaign participants sync: ${emailResults.success} succeeded, ${emailResults.failed} failed`);
} else {
logger.warn('No campaign participants found for initial sync');
}
} catch (emailError) {
logger.warn('Initial campaign participants sync failed:', {
message: emailError.message,
stack: emailError.stack
});
}
// Sync existing custom recipients
try {
const recipientsData = await nocodbService.getAll(nocodbService.tableIds.customRecipients);
const recipients = recipientsData?.list || [];
console.log('🔍 Initial sync - fetched custom recipients:', recipients?.length || 0);
if (recipients && recipients.length > 0) {
const campaigns = await nocodbService.getAllCampaigns();
const recipientResults = await listmonkService.bulkSyncCustomRecipients(recipients, campaigns);
logger.info(`📋 Initial custom recipients sync: ${recipientResults.success} succeeded, ${recipientResults.failed} failed`);
} else {
logger.warn('No custom recipients found for initial sync');
}
} catch (recipientError) {
logger.warn('Initial custom recipients sync failed:', {
message: recipientError.message,
stack: recipientError.stack
});
}
logger.info('✅ Initial Listmonk sync completed');
} catch (error) {
logger.error('Initial Listmonk sync failed:', {
message: error.message,
stack: error.stack
});
}
}, 5000); // Wait 5 seconds for app to fully start
}
})
.catch(error => {
logger.error('Failed to initialize Listmonk service:', error.message);
});
}
module.exports = listmonkService;

View File

@ -29,7 +29,8 @@ class NocoDBService {
calls: process.env.NOCODB_TABLE_CALLS, calls: process.env.NOCODB_TABLE_CALLS,
representativeResponses: process.env.NOCODB_TABLE_REPRESENTATIVE_RESPONSES, representativeResponses: process.env.NOCODB_TABLE_REPRESENTATIVE_RESPONSES,
responseUpvotes: process.env.NOCODB_TABLE_RESPONSE_UPVOTES, responseUpvotes: process.env.NOCODB_TABLE_RESPONSE_UPVOTES,
emailVerifications: process.env.NOCODB_TABLE_EMAIL_VERIFICATIONS emailVerifications: process.env.NOCODB_TABLE_EMAIL_VERIFICATIONS,
customRecipients: process.env.NOCODB_TABLE_CUSTOM_RECIPIENTS
}; };
// Validate that all table IDs are set // Validate that all table IDs are set
@ -437,6 +438,7 @@ class NocoDBService {
'Collect User Info': campaignData.collect_user_info, 'Collect User Info': campaignData.collect_user_info,
'Show Email Count': campaignData.show_email_count, 'Show Email Count': campaignData.show_email_count,
'Allow Email Editing': campaignData.allow_email_editing, 'Allow Email Editing': campaignData.allow_email_editing,
'Allow Custom Recipients': campaignData.allow_custom_recipients,
'Show Response Wall Button': campaignData.show_response_wall, 'Show Response Wall Button': campaignData.show_response_wall,
'Target Government Levels': campaignData.target_government_levels, 'Target Government Levels': campaignData.target_government_levels,
'Created By User ID': campaignData.created_by_user_id, 'Created By User ID': campaignData.created_by_user_id,
@ -470,6 +472,7 @@ class NocoDBService {
if (updates.collect_user_info !== undefined) mappedUpdates['Collect User Info'] = updates.collect_user_info; if (updates.collect_user_info !== undefined) mappedUpdates['Collect User Info'] = updates.collect_user_info;
if (updates.show_email_count !== undefined) mappedUpdates['Show Email Count'] = updates.show_email_count; if (updates.show_email_count !== undefined) mappedUpdates['Show Email Count'] = updates.show_email_count;
if (updates.allow_email_editing !== undefined) mappedUpdates['Allow Email Editing'] = updates.allow_email_editing; if (updates.allow_email_editing !== undefined) mappedUpdates['Allow Email Editing'] = updates.allow_email_editing;
if (updates.allow_custom_recipients !== undefined) mappedUpdates['Allow Custom Recipients'] = updates.allow_custom_recipients;
if (updates.show_response_wall !== undefined) mappedUpdates['Show Response Wall Button'] = updates.show_response_wall; if (updates.show_response_wall !== undefined) mappedUpdates['Show Response Wall Button'] = updates.show_response_wall;
if (updates.target_government_levels !== undefined) mappedUpdates['Target Government Levels'] = updates.target_government_levels; if (updates.target_government_levels !== undefined) mappedUpdates['Target Government Levels'] = updates.target_government_levels;
if (updates.updated_at !== undefined) mappedUpdates['UpdatedAt'] = updates.updated_at; if (updates.updated_at !== undefined) mappedUpdates['UpdatedAt'] = updates.updated_at;
@ -569,6 +572,25 @@ class NocoDBService {
} }
} }
async getCampaignVerifiedResponseCount(campaignId) {
try {
if (!this.tableIds.representativeResponses) {
console.warn('Representative responses table not configured, returning 0');
return 0;
}
// Get verified AND approved responses for this campaign
const response = await this.getAll(this.tableIds.representativeResponses, {
where: `(Campaign ID,eq,${campaignId})~and(Is Verified,eq,true)~and(Status,eq,approved)`,
limit: 1000 // Get enough to count
});
return response.pageInfo ? response.pageInfo.totalRows : (response.list ? response.list.length : 0);
} catch (error) {
console.error('Get campaign verified response count failed:', error);
return 0;
}
}
async getCampaignAnalytics(campaignId) { async getCampaignAnalytics(campaignId) {
try { try {
const response = await this.getAll(this.tableIds.campaignEmails, { const response = await this.getAll(this.tableIds.campaignEmails, {
@ -970,6 +992,212 @@ class NocoDBService {
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
} }
// ===== Custom Recipients Methods =====
/**
* Get all custom recipients for a campaign
*/
async getCustomRecipients(campaignId) {
if (!this.tableIds.customRecipients) {
throw new Error('Custom recipients table not configured');
}
try {
const response = await this.getAll(this.tableIds.customRecipients, {
where: `(Campaign ID,eq,${campaignId})`,
sort: '-CreatedAt',
limit: 1000
});
if (!response.list || response.list.length === 0) {
return [];
}
// Normalize the data structure
return response.list.map(record => ({
id: record.ID || record.Id || record.id,
campaign_id: record['Campaign ID'],
campaign_slug: record['Campaign Slug'],
recipient_name: record['Recipient Name'],
recipient_email: record['Recipient Email'],
recipient_title: record['Recipient Title'] || null,
recipient_organization: record['Recipient Organization'] || null,
notes: record['Notes'] || null,
is_active: record['Is Active'] !== false, // Default to true
created_at: record.CreatedAt,
updated_at: record.UpdatedAt
}));
} catch (error) {
console.error('Error in getCustomRecipients:', error.message);
throw error;
}
}
/**
* Get custom recipients by campaign slug
*/
async getCustomRecipientsBySlug(campaignSlug) {
if (!this.tableIds.customRecipients) {
throw new Error('Custom recipients table not configured');
}
try {
const response = await this.getAll(this.tableIds.customRecipients, {
where: `(Campaign Slug,eq,${campaignSlug})`,
sort: '-CreatedAt',
limit: 1000
});
if (!response.list || response.list.length === 0) {
return [];
}
// Normalize the data structure
return response.list.map(record => ({
id: record.ID || record.Id || record.id,
campaign_id: record['Campaign ID'],
campaign_slug: record['Campaign Slug'],
recipient_name: record['Recipient Name'],
recipient_email: record['Recipient Email'],
recipient_title: record['Recipient Title'] || null,
recipient_organization: record['Recipient Organization'] || null,
notes: record['Notes'] || null,
is_active: record['Is Active'] !== false, // Default to true
created_at: record.CreatedAt,
updated_at: record.UpdatedAt
}));
} catch (error) {
console.error('Error in getCustomRecipientsBySlug:', error.message);
throw error;
}
}
/**
* Create a new custom recipient
*/
async createCustomRecipient(recipientData) {
if (!this.tableIds.customRecipients) {
throw new Error('Custom recipients table not configured');
}
const data = {
'Campaign ID': recipientData.campaign_id,
'Campaign Slug': recipientData.campaign_slug,
'Recipient Name': recipientData.recipient_name,
'Recipient Email': recipientData.recipient_email,
'Recipient Title': recipientData.recipient_title || null,
'Recipient Organization': recipientData.recipient_organization || null,
'Notes': recipientData.notes || null,
'Is Active': recipientData.is_active !== false // Default to true
};
const created = await this.create(this.tableIds.customRecipients, data);
// Return normalized data
return {
id: created.ID || created.Id || created.id,
campaign_id: created['Campaign ID'],
campaign_slug: created['Campaign Slug'],
recipient_name: created['Recipient Name'],
recipient_email: created['Recipient Email'],
recipient_title: created['Recipient Title'] || null,
recipient_organization: created['Recipient Organization'] || null,
notes: created['Notes'] || null,
is_active: created['Is Active'] !== false,
created_at: created.CreatedAt,
updated_at: created.UpdatedAt
};
}
/**
* Update a custom recipient
*/
async updateCustomRecipient(recipientId, updateData) {
if (!this.tableIds.customRecipients) {
throw new Error('Custom recipients table not configured');
}
const data = {};
if (updateData.recipient_name !== undefined) {
data['Recipient Name'] = updateData.recipient_name;
}
if (updateData.recipient_email !== undefined) {
data['Recipient Email'] = updateData.recipient_email;
}
if (updateData.recipient_title !== undefined) {
data['Recipient Title'] = updateData.recipient_title;
}
if (updateData.recipient_organization !== undefined) {
data['Recipient Organization'] = updateData.recipient_organization;
}
if (updateData.notes !== undefined) {
data['Notes'] = updateData.notes;
}
if (updateData.is_active !== undefined) {
data['Is Active'] = updateData.is_active;
}
const updated = await this.update(this.tableIds.customRecipients, recipientId, data);
// Return normalized data
return {
id: updated.ID || updated.Id || updated.id,
campaign_id: updated['Campaign ID'],
campaign_slug: updated['Campaign Slug'],
recipient_name: updated['Recipient Name'],
recipient_email: updated['Recipient Email'],
recipient_title: updated['Recipient Title'] || null,
recipient_organization: updated['Recipient Organization'] || null,
notes: updated['Notes'] || null,
is_active: updated['Is Active'] !== false,
created_at: updated.CreatedAt,
updated_at: updated.UpdatedAt
};
}
/**
* Delete a custom recipient
*/
async deleteCustomRecipient(recipientId) {
if (!this.tableIds.customRecipients) {
throw new Error('Custom recipients table not configured');
}
try {
await this.client.delete(`${this.getTableUrl(this.tableIds.customRecipients)}/${recipientId}`);
return true;
} catch (error) {
if (error.response?.status === 404) {
return false;
}
throw error;
}
}
/**
* Delete all custom recipients for a campaign
*/
async deleteCustomRecipientsByCampaign(campaignId) {
if (!this.tableIds.customRecipients) {
throw new Error('Custom recipients table not configured');
}
try {
const recipients = await this.getCustomRecipients(campaignId);
let deletedCount = 0;
for (const recipient of recipients) {
const deleted = await this.deleteCustomRecipient(recipient.id);
if (deleted) deletedCount++;
}
return deletedCount;
} catch (error) {
console.error('Error deleting custom recipients by campaign:', error.message);
throw error;
}
}
} }
module.exports = new NocoDBService(); module.exports = new NocoDBService();

View File

@ -18,6 +18,14 @@ NOCODB_PROJECT_ID=your_project_id
# SMTP_FROM_EMAIL=your-sender@domain.com # SMTP_FROM_EMAIL=your-sender@domain.com
# SMTP_FROM_NAME="Your Campaign Name" # SMTP_FROM_NAME="Your Campaign Name"
# Listmonk Configuration (Email List Management)
# Enable to sync campaign participants to Listmonk email lists
LISTMONK_API_URL=http://listmonk_app:9000/api
LISTMONK_USERNAME=API
LISTMONK_PASSWORD=your_listmonk_api_password
LISTMONK_SYNC_ENABLED=false
LISTMONK_INITIAL_SYNC=false
# Admin Configuration # Admin Configuration
# Set a strong password for admin access # Set a strong password for admin access
ADMIN_PASSWORD=change_this_to_a_strong_password ADMIN_PASSWORD=change_this_to_a_strong_password

View File

@ -1081,6 +1081,12 @@ create_campaigns_table() {
"uidt": "Checkbox", "uidt": "Checkbox",
"cdf": "false" "cdf": "false"
}, },
{
"column_name": "allow_custom_recipients",
"title": "Allow Custom Recipients",
"uidt": "Checkbox",
"cdf": "false"
},
{ {
"column_name": "show_response_wall", "column_name": "show_response_wall",
"title": "Show Response Wall Button", "title": "Show Response Wall Button",
@ -1645,6 +1651,76 @@ create_users_table() {
create_table "$base_id" "influence_users" "$table_data" "User authentication and management" create_table "$base_id" "influence_users" "$table_data" "User authentication and management"
} }
# Function to create the custom recipients table
create_custom_recipients_table() {
local base_id=$1
local table_data='{
"table_name": "influence_custom_recipients",
"title": "Custom Recipients",
"columns": [
{
"column_name": "id",
"title": "ID",
"uidt": "ID",
"pk": true,
"ai": true,
"rqd": true
},
{
"column_name": "campaign_id",
"title": "Campaign ID",
"uidt": "Number",
"rqd": true
},
{
"column_name": "campaign_slug",
"title": "Campaign Slug",
"uidt": "SingleLineText",
"rqd": true
},
{
"column_name": "recipient_name",
"title": "Recipient Name",
"uidt": "SingleLineText",
"rqd": true
},
{
"column_name": "recipient_email",
"title": "Recipient Email",
"uidt": "Email",
"rqd": true
},
{
"column_name": "recipient_title",
"title": "Recipient Title",
"uidt": "SingleLineText",
"rqd": false
},
{
"column_name": "recipient_organization",
"title": "Recipient Organization",
"uidt": "SingleLineText",
"rqd": false
},
{
"column_name": "notes",
"title": "Notes",
"uidt": "LongText",
"rqd": false
},
{
"column_name": "is_active",
"title": "Is Active",
"uidt": "Checkbox",
"cdf": "true"
}
]
}'
create_table "$base_id" "influence_custom_recipients" "$table_data" "Custom email recipients for campaigns"
}
# Function to create the email verifications table # Function to create the email verifications table
create_email_verifications_table() { create_email_verifications_table() {
local base_id=$1 local base_id=$1
@ -1744,6 +1820,7 @@ update_env_with_table_ids() {
local representative_responses_table_id=$9 local representative_responses_table_id=$9
local response_upvotes_table_id=${10} local response_upvotes_table_id=${10}
local email_verifications_table_id=${11} local email_verifications_table_id=${11}
local custom_recipients_table_id=${12}
print_status "Updating .env file with NocoDB project and table IDs..." print_status "Updating .env file with NocoDB project and table IDs..."
@ -1782,6 +1859,7 @@ update_env_with_table_ids() {
update_env_var "NOCODB_TABLE_REPRESENTATIVE_RESPONSES" "$representative_responses_table_id" update_env_var "NOCODB_TABLE_REPRESENTATIVE_RESPONSES" "$representative_responses_table_id"
update_env_var "NOCODB_TABLE_RESPONSE_UPVOTES" "$response_upvotes_table_id" update_env_var "NOCODB_TABLE_RESPONSE_UPVOTES" "$response_upvotes_table_id"
update_env_var "NOCODB_TABLE_EMAIL_VERIFICATIONS" "$email_verifications_table_id" update_env_var "NOCODB_TABLE_EMAIL_VERIFICATIONS" "$email_verifications_table_id"
update_env_var "NOCODB_TABLE_CUSTOM_RECIPIENTS" "$custom_recipients_table_id"
print_success "Successfully updated .env file with all table IDs" print_success "Successfully updated .env file with all table IDs"
@ -1798,6 +1876,7 @@ update_env_with_table_ids() {
print_status "NOCODB_TABLE_CALLS=$call_logs_table_id" print_status "NOCODB_TABLE_CALLS=$call_logs_table_id"
print_status "NOCODB_TABLE_REPRESENTATIVE_RESPONSES=$representative_responses_table_id" print_status "NOCODB_TABLE_REPRESENTATIVE_RESPONSES=$representative_responses_table_id"
print_status "NOCODB_TABLE_RESPONSE_UPVOTES=$response_upvotes_table_id" print_status "NOCODB_TABLE_RESPONSE_UPVOTES=$response_upvotes_table_id"
print_status "NOCODB_TABLE_CUSTOM_RECIPIENTS=$custom_recipients_table_id"
} }
@ -1926,8 +2005,15 @@ main() {
exit 1 exit 1
fi fi
# Create custom recipients table
CUSTOM_RECIPIENTS_TABLE_ID=$(create_custom_recipients_table "$BASE_ID")
if [[ $? -ne 0 ]]; then
print_error "Failed to create custom recipients table"
exit 1
fi
# Validate all table IDs were created successfully # 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" "$CALL_LOGS_TABLE_ID" "$REPRESENTATIVE_RESPONSES_TABLE_ID" "$RESPONSE_UPVOTES_TABLE_ID" "$EMAIL_VERIFICATIONS_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" "$REPRESENTATIVE_RESPONSES_TABLE_ID" "$RESPONSE_UPVOTES_TABLE_ID" "$EMAIL_VERIFICATIONS_TABLE_ID" "$CUSTOM_RECIPIENTS_TABLE_ID"; then
print_error "One or more table IDs are invalid" print_error "One or more table IDs are invalid"
exit 1 exit 1
fi fi
@ -1952,6 +2038,7 @@ main() {
table_mapping["influence_representative_responses"]="$REPRESENTATIVE_RESPONSES_TABLE_ID" table_mapping["influence_representative_responses"]="$REPRESENTATIVE_RESPONSES_TABLE_ID"
table_mapping["influence_response_upvotes"]="$RESPONSE_UPVOTES_TABLE_ID" table_mapping["influence_response_upvotes"]="$RESPONSE_UPVOTES_TABLE_ID"
table_mapping["influence_email_verifications"]="$EMAIL_VERIFICATIONS_TABLE_ID" table_mapping["influence_email_verifications"]="$EMAIL_VERIFICATIONS_TABLE_ID"
table_mapping["influence_custom_recipients"]="$CUSTOM_RECIPIENTS_TABLE_ID"
# Get source table information # Get source table information
local source_tables_response local source_tables_response
@ -2029,7 +2116,7 @@ main() {
fi fi
# Update .env file with table IDs # 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" "$CALL_LOGS_TABLE_ID" "$REPRESENTATIVE_RESPONSES_TABLE_ID" "$RESPONSE_UPVOTES_TABLE_ID" "$EMAIL_VERIFICATIONS_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" "$REPRESENTATIVE_RESPONSES_TABLE_ID" "$RESPONSE_UPVOTES_TABLE_ID" "$EMAIL_VERIFICATIONS_TABLE_ID" "$CUSTOM_RECIPIENTS_TABLE_ID"
print_status "" print_status ""
print_status "============================================================" print_status "============================================================"

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

View File

@ -0,0 +1,42 @@
---
date: 2025-09-24
---
Okay! Wow! Its been nearly 2 months since I wrote a blog update for this system.
We have pushed out [influence](https://influence.bnkops.com) as a beta product, and will be pushing it out to get feedback from real users over the next month.
Our campaign software Map was also used by a real campaign for the first time, and we have some great feedback to incorporate into the system.
## What We've Built Since August
Here's a quick rundown of everything we've committed to the codebase over the past three months:
### Influence App - Major Launch
- **Complete UI Overhaul**: Built an entirely new user interface and user system from the ground up
- **Response Wall**: Developed a comprehensive response wall system where elected officials can respond to campaigns, including verified response system with QR codes and verify buttons
- **Campaign Management**: Created new system for creating campaigns from the main site dashboard with campaign cover photos and phone numbers
- **Social Features**: Added social share buttons and site info improvements
- **Geocoding Enhancements**: Implemented automatic scanning of NocoDB locations to build geo-locations, plus premium Mapbox option for better street address matching
- **User Management**: Built password updater for users/admins and improved overall user management
- **Network Integration**: Integrated Influence into the Changemaker network
- **Monitoring & Maintenance**: Added health check utility, logger, metrics, backup, and SMTP toggle scripts
### Map App - Production Ready
- **Map Cuts Feature**: Built a comprehensive "cuts" system for dividing territories, including assignment workflows, print views, and spatial data handling
- **Public Shifts**: Implemented new public shifts system for volunteer coordination
- **Performance**: Optimized loading for maps with 1000+ locations and improved shift loading speeds
- **Admin Improvements**: Major refactor of admin.js into readable, maintainable files, plus new NocoDB admin section with database search
- **Temp Users**: Enhanced temporary user system with proper access controls and limited data sending
- **Data Tools**: Added CSV import reporting and ListMonk synchronization
- **UI/UX**: Standardized z-indexes, updated pop-ups, fixed menu bugs, and improved cut overlays
- **CORS & Auth**: Fixed authentication, lockouts, and CORS for local dev access
### Infrastructure & DevOps
- **Documentation**: Updated MkDocs documentation with search functionality
- **Build System**: Improved build-nocodb script to migrate data and auto-input URLs to .env
- **Docker**: Cleaned up docker-compose configuration and fixed container duplication issues
- **Configuration**: Updated homepage configs, Cloudflare tunnel settings, and general system configs
The velocity has been incredible - we went from concept to production with Influence in just a few weeks, and Map has evolved into a robust campaigning tool that's battle-tested in real elections. Looking forward to incorporating user feedback and continuing to iterate!