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:
2025-10-25 12:45:35 -06:00
parent f50c515eaa
commit 019b8d7d05
26 changed files with 4125 additions and 156 deletions

View File

@@ -34,7 +34,7 @@ const upload = multer({
const allowedTypes = /jpeg|jpg|png|gif|webp/;
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
const mimetype = allowedTypes.test(file.mimetype);
if (mimetype && extname) {
return cb(null, true);
} else {
@@ -75,10 +75,10 @@ async function cacheRepresentatives(postalCode, representatives, representData)
city: representData.city,
province: representData.province
});
// Cache representatives using the existing method
const result = await nocoDB.storeRepresentatives(postalCode, representatives);
if (result.success) {
console.log(`Successfully cached ${result.count} representatives for ${postalCode}`);
} else {
@@ -95,7 +95,7 @@ class CampaignsController {
async getPublicCampaigns(req, res, next) {
try {
const campaigns = await nocoDB.getAllCampaigns();
// Filter to only active campaigns and normalize data structure
const activeCampaigns = await Promise.all(
campaigns
@@ -105,7 +105,7 @@ class CampaignsController {
})
.map(async (campaign) => {
const id = campaign.ID || campaign.Id || campaign.id;
// Debug: Log specific fields we're looking for
console.log(`Campaign ${id}:`, {
'Show Call Count': campaign['Show Call Count'],
@@ -113,7 +113,7 @@ class CampaignsController {
'Show Email Count': campaign['Show Email Count'],
'show_email_count': campaign.show_email_count
});
// Get email count if show_email_count is enabled
let emailCount = null;
const showEmailCount = campaign['Show Email Count'] || campaign.show_email_count;
@@ -132,9 +132,16 @@ class CampaignsController {
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 normalizedTargetLevels = normalizeTargetLevels(rawTargetLevels);
// Return only public-facing information
return {
id,
@@ -149,7 +156,8 @@ class CampaignsController {
target_government_levels: normalizedTargetLevels,
created_at: campaign.CreatedAt || campaign.created_at,
emailCount,
callCount
callCount,
verifiedResponseCount
};
})
);
@@ -172,7 +180,7 @@ class CampaignsController {
async getAllCampaigns(req, res, next) {
try {
const campaigns = await nocoDB.getAllCampaigns();
// Get email counts for each campaign and normalize data structure
const campaignsWithCounts = await Promise.all(campaigns.map(async (campaign) => {
const id = campaign.ID || campaign.Id || campaign.id;
@@ -183,7 +191,7 @@ class CampaignsController {
const rawTargetLevels = campaign['Target Government Levels'] || campaign.target_government_levels;
const normalizedTargetLevels = normalizeTargetLevels(rawTargetLevels);
// Normalize campaign data structure for frontend
return {
id,
@@ -200,6 +208,7 @@ class CampaignsController {
collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info,
show_email_count: campaign['Show Email Count'] || campaign.show_email_count,
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,
target_government_levels: normalizedTargetLevels,
created_at: campaign.CreatedAt || campaign.created_at,
@@ -248,7 +257,7 @@ class CampaignsController {
try {
const { id } = req.params;
const campaign = await nocoDB.getCampaignById(id);
if (!campaign) {
return res.status(404).json({
success: false,
@@ -259,12 +268,12 @@ class CampaignsController {
// Debug logging
console.log('Campaign object keys:', Object.keys(campaign));
console.log('Campaign ID field:', campaign.ID, campaign.Id, campaign.id);
const normalizedId = campaign.ID || campaign.Id || campaign.id;
console.log('Using normalized ID:', normalizedId);
const emailCount = await nocoDB.getCampaignEmailCount(normalizedId);
// Normalize campaign data structure for frontend
res.json({
success: true,
@@ -283,6 +292,7 @@ class CampaignsController {
collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info,
show_email_count: campaign['Show Email Count'] || campaign.show_email_count,
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,
target_government_levels: normalizeTargetLevels(campaign['Target Government Levels'] || campaign.target_government_levels),
created_at: campaign.CreatedAt || campaign.created_at,
@@ -309,7 +319,7 @@ class CampaignsController {
try {
const { slug } = req.params;
const campaign = await nocoDB.getCampaignBySlug(slug);
if (!campaign) {
return res.status(404).json({
success: false,
@@ -317,8 +327,8 @@ class CampaignsController {
});
}
const campaignStatus = normalizeStatus(campaign['Status'] || campaign.status);
if (campaignStatus !== 'active') {
const campaignStatus = normalizeStatus(campaign['Status'] || campaign.status);
if (campaignStatus !== 'active') {
return res.status(403).json({
success: false,
error: 'Campaign is not currently active'
@@ -370,6 +380,7 @@ class CampaignsController {
show_email_count: campaign['Show Email Count'] || campaign.show_email_count,
show_call_count: campaign['Show Call Count'] || campaign.show_call_count,
allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing,
allow_custom_recipients: campaign['Allow Custom Recipients'] || campaign.allow_custom_recipients,
show_response_wall: campaign['Show Response Wall Button'] || campaign.show_response_wall,
target_government_levels: normalizeTargetLevels(campaign['Target Government Levels'] || campaign.target_government_levels),
emailCount,
@@ -390,6 +401,22 @@ class CampaignsController {
// Create new campaign
async createCampaign(req, res, next) {
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 {
title,
description,
@@ -402,6 +429,7 @@ class CampaignsController {
collect_user_info = true,
show_email_count = true,
allow_email_editing = false,
allow_custom_recipients = false,
target_government_levels = ['Federal', 'Provincial', 'Municipal']
} = req.body;
@@ -410,7 +438,7 @@ class CampaignsController {
const ownerName = req.user?.name ?? req.session?.userName ?? null;
const normalizedStatus = normalizeStatus(status, 'draft');
let slug = generateSlug(title);
// Ensure slug is unique
let counter = 1;
let originalSlug = slug;
@@ -432,6 +460,7 @@ class CampaignsController {
collect_user_info,
show_email_count,
allow_email_editing,
allow_custom_recipients,
// NocoDB MultiSelect expects an array of values
target_government_levels: normalizeTargetLevels(target_government_levels),
// Add user ownership data
@@ -462,6 +491,7 @@ class CampaignsController {
collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info,
show_email_count: campaign['Show Email Count'] || campaign.show_email_count,
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),
created_at: campaign.CreatedAt || campaign.created_at,
updated_at: campaign.UpdatedAt || campaign.updated_at,
@@ -548,7 +578,28 @@ class CampaignsController {
} else {
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);
if (updates.status !== undefined) {
@@ -578,7 +629,7 @@ class CampaignsController {
if (oldSlug && newSlug && oldSlug !== newSlug) {
console.log(`Campaign slug changed from '${oldSlug}' to '${newSlug}', updating references...`);
const cascadeResult = await nocoDB.updateCampaignSlugReferences(id, oldSlug, newSlug);
if (cascadeResult.success) {
console.log(`Successfully updated slug references: ${cascadeResult.updatedCampaignEmails} campaign emails, ${cascadeResult.updatedCallLogs} call logs`);
} else {
@@ -604,6 +655,7 @@ class CampaignsController {
collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info,
show_email_count: campaign['Show Email Count'] || campaign.show_email_count,
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),
created_at: campaign.CreatedAt || campaign.created_at,
updated_at: campaign.UpdatedAt || campaign.updated_at,
@@ -627,7 +679,7 @@ class CampaignsController {
async deleteCampaign(req, res, next) {
try {
const { id } = req.params;
await nocoDB.deleteCampaign(id);
res.json({
@@ -694,13 +746,13 @@ class CampaignsController {
});
}
// Use custom email content if provided (when email editing is enabled), otherwise use campaign defaults
const allowEmailEditing = campaign['Allow Email Editing'] || campaign.allow_email_editing;
const subject = (allowEmailEditing && customEmailSubject)
? customEmailSubject
// Use custom email content if provided (when email editing is enabled), otherwise use campaign defaults
const allowEmailEditing = campaign['Allow Email Editing'] || campaign.allow_email_editing;
const subject = (allowEmailEditing && customEmailSubject)
? customEmailSubject
: (campaign['Email Subject'] || campaign.email_subject);
const message = (allowEmailEditing && customEmailBody)
? customEmailBody
const message = (allowEmailEditing && customEmailBody)
? customEmailBody
: (campaign['Email Body'] || campaign.email_body);
let emailResult = { success: true };
@@ -833,7 +885,7 @@ class CampaignsController {
async getRepresentativesForCampaign(req, res, next) {
try {
const { slug, postalCode } = req.params;
// Get campaign to check target levels
const campaign = await nocoDB.getCampaignBySlug(slug);
if (!campaign) {
@@ -855,13 +907,13 @@ class CampaignsController {
const formattedPostalCode = postalCode.replace(/\s/g, '').toUpperCase();
let representatives = [];
let result = null;
// Try to check cached data first, but don't fail if NocoDB is down
let cachedData = [];
try {
cachedData = await nocoDB.getRepresentativesByPostalCode(formattedPostalCode);
console.log(`Cache check for ${formattedPostalCode}: found ${cachedData.length} records`);
if (cachedData && cachedData.length > 0) {
representatives = cachedData;
console.log(`Using cached representatives for ${formattedPostalCode}`);
@@ -874,13 +926,13 @@ class CampaignsController {
if (representatives.length === 0) {
console.log(`Fetching representatives from Represent API for ${formattedPostalCode}`);
result = await representAPI.getRepresentativesByPostalCode(postalCode);
// Process representatives from both concordance and centroid
// Add concordance representatives (if any)
if (result.representatives_concordance && result.representatives_concordance.length > 0) {
representatives = representatives.concat(result.representatives_concordance);
}
// Add centroid representatives (if any) - these are the actual elected officials
if (result.representatives_centroid && result.representatives_centroid.length > 0) {
representatives = representatives.concat(result.representatives_centroid);
@@ -915,10 +967,10 @@ class CampaignsController {
const filteredRepresentatives = representatives.filter(rep => {
const repLevel = rep.elected_office ? rep.elected_office.toLowerCase() : 'other';
return targetLevels.some(targetLevel => {
const target = targetLevel.toLowerCase();
if (target === 'federal' && (repLevel.includes('mp') || repLevel.includes('member of parliament'))) {
return true;
}
@@ -931,14 +983,38 @@ class CampaignsController {
if (target === 'school board' && repLevel.includes('school')) {
return true;
}
return false;
});
});
// 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({
success: true,
representatives: filteredRepresentatives,
representatives: allRecipients,
location: {
city: result?.city || cachedData[0]?.city || 'Alberta',
province: result?.province || cachedData[0]?.province || 'AB'
@@ -990,9 +1066,9 @@ class CampaignsController {
});
}
}
const analytics = await nocoDB.getCampaignAnalytics(id);
res.json({
success: true,
analytics
@@ -1102,7 +1178,7 @@ class CampaignsController {
// Build URL based on type
const appUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
let targetUrl;
if (type === 'response-wall') {
targetUrl = `${appUrl}/response-wall.html?campaign=${slug}`;
} else {

View File

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

View File

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

View File

@@ -61,6 +61,7 @@ const csrfExemptRoutes = [
'/api/metrics',
'/api/config',
'/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/representatives/postal/', // Read-only operation
'/api/campaigns/public' // Public read operations
@@ -77,12 +78,30 @@ const conditionalCsrfProtection = (req, res, 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
csrfProtection(req, res, (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
*/
const getCsrfToken = (req, res) => {
res.json({
csrfToken: req.csrfToken()
});
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({
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 = {

View File

@@ -762,6 +762,97 @@
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) {
.admin-nav {
flex-direction: column;
@@ -803,6 +894,7 @@
<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="users">User Management</button>
<button class="nav-btn" data-tab="listmonk">📧 Email Sync</button>
</nav>
<!-- Success/Error Messages -->
@@ -918,6 +1010,10 @@ Sincerely,
<input type="checkbox" id="create-allow-editing" name="allow_email_editing">
<label for="create-allow-editing">✏️ Allow Email Editing</label>
</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">
<input type="checkbox" id="create-show-response-wall" name="show_response_wall">
<label for="create-show-response-wall">💬 Show Response Wall Button</label>
@@ -925,6 +1021,15 @@ Sincerely,
</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="form-group">
<div class="checkbox-group">
@@ -1039,6 +1144,10 @@ Sincerely,
<input type="checkbox" id="edit-allow-editing" name="allow_email_editing">
<label for="edit-allow-editing">✏️ Allow Email Editing</label>
</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">
<input type="checkbox" id="edit-show-response-wall" name="show_response_wall">
<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>
</div>
</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>
<!-- Response Moderation Tab -->
@@ -1124,6 +1294,80 @@ Sincerely,
<!-- Users will be loaded here -->
</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>
<!-- User Modal -->
@@ -1199,8 +1443,54 @@ Sincerely,
</div>
</div>
<!-- Bulk Import Recipients Modal -->
<div id="bulk-import-modal" class="modal-overlay hidden">
<div class="modal-content">
<div class="modal-header">
<h3>Bulk Import Recipients</h3>
<button class="modal-close" data-action="close-bulk-import-modal">&times;</button>
</div>
<div class="bulk-import-help">
<h4>📋 CSV Format</h4>
<p>Upload or paste CSV data with the following columns:</p>
<code>recipient_name,recipient_email,recipient_title,recipient_organization,notes</code>
<p style="margin-top: 0.5rem;"><strong>Example:</strong></p>
<code style="display: block; white-space: pre; font-size: 0.85rem;">John Smith,john@example.com,CEO,Acme Corp,Important contact
Jane Doe,jane@example.com,Director,Example Inc,</code>
</div>
<form id="bulk-import-form">
<div class="form-group">
<label for="bulk-csv-file">Upload CSV File</label>
<input type="file" id="bulk-csv-file" accept=".csv,text/csv">
</div>
<div style="text-align: center; margin: 1rem 0; color: #666;">
<strong>- OR -</strong>
</div>
<div class="form-group">
<label for="bulk-csv-text">Paste CSV Data</label>
<textarea id="bulk-csv-text" rows="8" placeholder="Paste your CSV data here..."></textarea>
</div>
<div class="form-row">
<button type="submit" class="btn btn-primary">Import Recipients</button>
<button type="button" class="btn btn-secondary" data-action="close-bulk-import-modal">Cancel</button>
</div>
</form>
<div id="bulk-import-results" class="hidden" style="margin-top: 1rem;">
<!-- Results will be shown here -->
</div>
</div>
</div>
<script src="js/api-client.js"></script>
<script src="js/auth.js"></script>
<script src="js/custom-recipients.js"></script>
<script src="js/listmonk-admin.js"></script>
<script src="js/admin.js"></script>
</body>

View File

@@ -303,6 +303,11 @@
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 {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
@@ -321,11 +326,30 @@
border-radius: 50%;
object-fit: cover;
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 {
margin: 0 0 0.25rem 0;
color: #2c3e50;
display: flex;
align-items: center;
gap: 0.5rem;
}
.rep-details p {
@@ -764,9 +788,13 @@
<footer style="text-align: center; margin-top: 2rem; padding: 1rem; border-top: 1px solid #e9ecef;">
<p>&copy; 2025 <a href="https://bnkops.com/" target="_blank">BNKops</a> Influence Tool. Connect with democracy.</p>
<p><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>
<script src="/js/api-client.js"></script>
<script src="/js/campaign.js"></script>
<script>
// Update footer links with APP_URL if needed for cross-origin scenarios

View File

@@ -1913,6 +1913,23 @@ footer a:hover {
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 {
display: flex;
align-items: center;

View File

@@ -8,6 +8,7 @@ class AdminPanel {
}
async init() {
console.log('AdminPanel init started');
// Check authentication first
if (typeof authManager !== 'undefined') {
this.authManager = authManager;
@@ -23,9 +24,12 @@ class AdminPanel {
return;
}
console.log('Setting up event listeners and form interactions');
this.setupEventListeners();
this.setupFormInteractions();
this.setupCustomRecipientsHandlers();
this.loadCampaigns();
console.log('AdminPanel init completed');
}
setupUserInterface() {
@@ -573,6 +577,7 @@ class AdminPanel {
campaignFormData.append('show_email_count', formData.get('show_email_count') === '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('allow_custom_recipients', formData.get('allow_custom_recipients') === 'on');
// Handle target_government_levels array
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="allow_email_editing"]').checked = campaign.allow_email_editing;
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
let targetLevels = [];
@@ -682,6 +709,7 @@ class AdminPanel {
updateFormData.append('show_email_count', formData.get('show_email_count') === '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('allow_custom_recipients', formData.get('allow_custom_recipients') === 'on');
// Handle target_government_levels array
const targetLevels = Array.from(formData.getAll('target_government_levels'));
@@ -1329,6 +1357,53 @@ class AdminPanel {
div.textContent = text;
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

View File

@@ -2,14 +2,66 @@
class APIClient {
constructor() {
this.baseURL = '/api';
this.csrfToken = null;
this.csrfTokenPromise = null;
}
async makeRequest(endpoint, options = {}) {
/**
* 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();
}
const config = {
headers: {
'Content-Type': 'application/json',
...(needsCsrf && this.csrfToken ? { 'X-CSRF-Token': this.csrfToken } : {}),
...options.headers
},
credentials: 'include', // Important: include cookies for CSRF
...options
};
@@ -17,7 +69,21 @@ class APIClient {
const response = await fetch(`${this.baseURL}${endpoint}`, config);
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 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
const error = new Error(data.error || data.message || `HTTP ${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
// But DO set CSRF token header
const config = {
method: 'POST',
body: formData
headers: {
...(this.csrfToken ? { 'X-CSRF-Token': this.csrfToken } : {})
},
body: formData,
credentials: 'include' // Important: include cookies for CSRF
};
try {
const response = await fetch(`${this.baseURL}${endpoint}`, config);
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 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}`);
error.status = response.status;
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
// But DO set CSRF token header
const config = {
method: 'PUT',
body: formData
headers: {
...(this.csrfToken ? { 'X-CSRF-Token': this.csrfToken } : {})
},
body: formData,
credentials: 'include' // Important: include cookies for CSRF
};
try {
const response = await fetch(`${this.baseURL}${endpoint}`, config);
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 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}`);
error.status = response.status;
error.data = data;

View File

@@ -279,8 +279,7 @@ class CampaignPage {
this.showLoading('Loading campaign...');
try {
const response = await fetch(`/api/campaigns/${this.campaignSlug}`);
const data = await response.json();
const data = await window.apiClient.get(`/campaigns/${this.campaignSlug}`);
if (!data.success) {
throw new Error(data.error || 'Failed to load campaign');
@@ -587,19 +586,11 @@ class CampaignPage {
async trackUserInfo() {
try {
const response = await fetch(`/api/campaigns/${this.campaignSlug}/track-user`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
userEmail: this.userInfo.userEmail,
userName: this.userInfo.userName,
postalCode: this.userInfo.postalCode
})
const data = await window.apiClient.post(`/campaigns/${this.campaignSlug}/track-user`, {
userEmail: this.userInfo.userEmail,
userName: this.userInfo.userName,
postalCode: this.userInfo.postalCode
});
const data = await response.json();
if (!data.success) {
console.warn('Failed to track user info:', data.error);
@@ -615,8 +606,7 @@ class CampaignPage {
this.showLoading('Finding your representatives...');
try {
const response = await fetch(`/api/campaigns/${this.campaignSlug}/representatives/${this.userInfo.postalCode}`);
const data = await response.json();
const data = await window.apiClient.get(`/campaigns/${this.campaignSlug}/representatives/${this.userInfo.postalCode}`);
if (!data.success) {
throw new Error(data.error || 'Failed to load representatives');
@@ -646,14 +636,17 @@ class CampaignPage {
}
list.innerHTML = this.representatives.map(rep => `
<div class="rep-card">
<div class="rep-card ${rep.is_custom_recipient ? 'custom-recipient' : ''}">
<div class="rep-info">
${rep.photo_url ?
`<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">
<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.party_name || ''}</p>
${rep.email ? `<p>📧 ${rep.email}</p>` : ''}
@@ -666,11 +659,12 @@ class CampaignPage {
data-email="${rep.email}"
data-name="${rep.name}"
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
</button>
` : ''}
${this.getPhoneNumber(rep) ? `
${this.getPhoneNumber(rep) && !rep.is_custom_recipient ? `
<button class="btn btn-success" data-action="call-representative"
data-phone="${this.getPhoneNumber(rep)}"
data-name="${rep.name}"
@@ -698,7 +692,8 @@ class CampaignPage {
const name = e.target.dataset.name;
const title = e.target.dataset.title;
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,23 +767,15 @@ class CampaignPage {
async trackCall(phone, name, title, officeType) {
try {
const response = await fetch(`/api/campaigns/${this.campaignSlug}/track-call`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
representativeName: name,
representativeTitle: title || '',
phoneNumber: phone,
officeType: officeType || '',
userEmail: this.userInfo.userEmail,
userName: this.userInfo.userName,
postalCode: this.userInfo.postalCode
})
const data = await window.apiClient.post(`/campaigns/${this.campaignSlug}/track-call`, {
representativeName: name,
representativeTitle: title || '',
phoneNumber: phone,
officeType: officeType || '',
userEmail: this.userInfo.userEmail,
userName: this.userInfo.userName,
postalCode: this.userInfo.postalCode
});
const data = await response.json();
if (data.success) {
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;
// 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') {
this.openMailtoLink(recipientEmail);
this.openMailtoLink(recipientEmail, recipientName, recipientTitle, finalLevel);
} 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 body = encodeURIComponent(this.currentEmailBody || this.campaign.email_body);
const mailtoUrl = `mailto:${recipientEmail}?subject=${subject}&body=${body}`;
// Track the mailto click
this.trackEmail(recipientEmail, '', '', '', 'mailto');
this.trackEmail(recipientEmail, recipientName, recipientTitle, recipientLevel, 'mailto');
window.open(mailtoUrl);
}
@@ -839,15 +829,7 @@ class CampaignPage {
emailData.customEmailBody = this.currentEmailBody || this.campaign.email_body;
}
const response = await fetch(`/api/campaigns/${this.campaignSlug}/send-email`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(emailData)
});
const data = await response.json();
const data = await window.apiClient.post(`/campaigns/${this.campaignSlug}/send-email`, emailData);
if (data.success) {
this.showSuccess('Email sent successfully!');
@@ -870,21 +852,15 @@ class CampaignPage {
async trackEmail(recipientEmail, recipientName, recipientTitle, recipientLevel, emailMethod) {
try {
await fetch(`/api/campaigns/${this.campaignSlug}/send-email`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
userEmail: this.userInfo.userEmail,
userName: this.userInfo.userName,
postalCode: this.userInfo.postalCode,
recipientEmail,
recipientName,
recipientTitle,
recipientLevel,
emailMethod
})
await window.apiClient.post(`/campaigns/${this.campaignSlug}/send-email`, {
userEmail: this.userInfo.userEmail,
userName: this.userInfo.userName,
postalCode: this.userInfo.postalCode,
recipientEmail,
recipientName,
recipientTitle,
recipientLevel,
emailMethod
});
} catch (error) {
console.error('Failed to track email:', error);

View File

@@ -159,6 +159,14 @@ class CampaignsGrid {
</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
? campaign.target_government_levels.map(level => `<span class="level-badge">${level}</span>`).join('')
: '';
@@ -215,6 +223,7 @@ class CampaignsGrid {
</svg>
</button>
</div>
${verifiedResponseBadge}
<div class="campaign-card-action">
<span class="btn-link">Learn More & Participate →</span>
</div>

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ const { body, param, validationResult } = require('express-validator');
const representativesController = require('../controllers/representatives');
const emailsController = require('../controllers/emails');
const campaignsController = require('../controllers/campaigns');
const customRecipientsController = require('../controllers/customRecipients');
const responsesController = require('../controllers/responses');
const rateLimiter = require('../utils/rate-limiter');
const { requireAdmin, requireAuth, requireNonTemp, optionalAuth } = require('../middleware/auth');
@@ -12,6 +13,9 @@ const upload = require('../middleware/upload');
// Import user routes
const userRoutes = require('./users');
// Import Listmonk routes
const listmonkRoutes = require('./listmonk');
// Validation middleware
const handleValidationErrors = (req, res, next) => {
const errors = validationResult(req);
@@ -223,9 +227,66 @@ router.post(
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)
router.use('/admin/users', userRoutes);
// Listmonk email sync routes (admin only)
router.use('/listmonk', listmonkRoutes);
// Response Wall Routes
router.get('/campaigns/:slug/responses', rateLimiter.general, responsesController.getCampaignResponses);
router.get('/campaigns/:slug/response-stats', rateLimiter.general, responsesController.getResponseStats);

View File

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

View File

@@ -10,7 +10,7 @@ require('dotenv').config();
const logger = require('./utils/logger');
const metrics = require('./utils/metrics');
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 authRoutes = require('./routes/auth');
@@ -48,7 +48,11 @@ app.use(helmet({
}));
// 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.urlencoded({ extended: true, limit: '10mb' }));
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
app.get('/api/config', (req, res) => {
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
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));

View File

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

View File

@@ -29,7 +29,8 @@ class NocoDBService {
calls: process.env.NOCODB_TABLE_CALLS,
representativeResponses: process.env.NOCODB_TABLE_REPRESENTATIVE_RESPONSES,
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
@@ -437,6 +438,7 @@ class NocoDBService {
'Collect User Info': campaignData.collect_user_info,
'Show Email Count': campaignData.show_email_count,
'Allow Email Editing': campaignData.allow_email_editing,
'Allow Custom Recipients': campaignData.allow_custom_recipients,
'Show Response Wall Button': campaignData.show_response_wall,
'Target Government Levels': campaignData.target_government_levels,
'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.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_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.target_government_levels !== undefined) mappedUpdates['Target Government Levels'] = updates.target_government_levels;
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) {
try {
const response = await this.getAll(this.tableIds.campaignEmails, {
@@ -970,6 +992,212 @@ class NocoDBService {
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();