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:
parent
e5c32ad25a
commit
4d8b9effd0
161
influence/CSRF_FIX_SUMMARY.md
Normal file
161
influence/CSRF_FIX_SUMMARY.md
Normal 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`
|
||||||
243
influence/CUSTOM_RECIPIENTS_IMPLEMENTATION.md
Normal file
243
influence/CUSTOM_RECIPIENTS_IMPLEMENTATION.md
Normal 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
|
||||||
119
influence/DEBUGGING_CUSTOM_RECIPIENTS.md
Normal file
119
influence/DEBUGGING_CUSTOM_RECIPIENTS.md
Normal 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
|
||||||
@ -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'
|
||||||
|
|||||||
283
influence/app/controllers/customRecipients.js
Normal file
283
influence/app/controllers/customRecipients.js
Normal 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();
|
||||||
262
influence/app/controllers/listmonkController.js
Normal file
262
influence/app/controllers/listmonkController.js
Normal 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'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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 = {
|
||||||
|
|||||||
@ -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">×</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>
|
||||||
|
|||||||
@ -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>© 2025 <a href="https://bnkops.com/" target="_blank">BNKops</a> Influence Tool. Connect with democracy.</p>
|
<p>© 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
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
525
influence/app/public/js/custom-recipients.js
Normal file
525
influence/app/public/js/custom-recipients.js
Normal 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 = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": '''
|
||||||
|
};
|
||||||
|
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;
|
||||||
465
influence/app/public/js/listmonk-admin.js
Normal file
465
influence/app/public/js/listmonk-admin.js
Normal 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;
|
||||||
@ -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` +
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
26
influence/app/routes/listmonk.js
Normal file
26
influence/app/routes/listmonk.js
Normal 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;
|
||||||
@ -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'));
|
||||||
|
|||||||
832
influence/app/services/listmonk.js
Normal file
832
influence/app/services/listmonk.js
Normal 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;
|
||||||
@ -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();
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 "============================================================"
|
||||||
|
|||||||
BIN
mkdocs/.cache/plugin/social/62372a9d5b433f883e1358d69aafb99d.png
Normal file
BIN
mkdocs/.cache/plugin/social/62372a9d5b433f883e1358d69aafb99d.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 62 KiB |
42
mkdocs/docs/blog/posts/4.md
Normal file
42
mkdocs/docs/blog/posts/4.md
Normal 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!
|
||||||
|
|
||||||
Loading…
x
Reference in New Issue
Block a user