514 lines
20 KiB
JavaScript
514 lines
20 KiB
JavaScript
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 = {
|
|
locations: null,
|
|
users: null,
|
|
supportLevel1: null,
|
|
supportLevel2: null,
|
|
supportLevel3: null,
|
|
supportLevel4: null,
|
|
hasSign: null,
|
|
wantsSign: null,
|
|
signRegular: null,
|
|
signLarge: null,
|
|
signUnsure: null
|
|
};
|
|
|
|
// Debug logging for environment variables
|
|
console.log('🔍 Listmonk Environment Variables:');
|
|
console.log(` LISTMONK_SYNC_ENABLED: ${process.env.LISTMONK_SYNC_ENABLED}`);
|
|
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;
|
|
}
|
|
|
|
// 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) {
|
|
// Use the actual error message from checkConnection
|
|
throw new Error(`Cannot connect to Listmonk: ${this.lastError || 'Unknown connection error'}`);
|
|
}
|
|
|
|
// Create or get main lists
|
|
this.lists.locations = await this.ensureList({
|
|
name: 'Map Locations - All',
|
|
type: 'private',
|
|
optin: 'single',
|
|
tags: ['map', 'locations', 'automated'],
|
|
description: 'All locations from the map application'
|
|
});
|
|
|
|
this.lists.users = await this.ensureList({
|
|
name: 'Map Users - All',
|
|
type: 'private',
|
|
optin: 'single',
|
|
tags: ['map', 'users', 'automated'],
|
|
description: 'All registered users from the map application'
|
|
});
|
|
|
|
// Create support level lists
|
|
this.lists.supportLevel1 = await this.ensureList({
|
|
name: 'Support Level 1',
|
|
type: 'private',
|
|
optin: 'single',
|
|
tags: ['map', 'support-level-1', 'automated'],
|
|
description: 'Strong supporters (Level 1 - Green)'
|
|
});
|
|
|
|
this.lists.supportLevel2 = await this.ensureList({
|
|
name: 'Support Level 2',
|
|
type: 'private',
|
|
optin: 'single',
|
|
tags: ['map', 'support-level-2', 'automated'],
|
|
description: 'Moderate supporters (Level 2 - Yellow)'
|
|
});
|
|
|
|
this.lists.supportLevel3 = await this.ensureList({
|
|
name: 'Support Level 3',
|
|
type: 'private',
|
|
optin: 'single',
|
|
tags: ['map', 'support-level-3', 'automated'],
|
|
description: 'Weak supporters (Level 3 - Orange)'
|
|
});
|
|
|
|
this.lists.supportLevel4 = await this.ensureList({
|
|
name: 'Support Level 4',
|
|
type: 'private',
|
|
optin: 'single',
|
|
tags: ['map', 'support-level-4', 'automated'],
|
|
description: 'Opponents (Level 4 - Red)'
|
|
});
|
|
|
|
// Create sign status lists
|
|
this.lists.hasSign = await this.ensureList({
|
|
name: 'Has Campaign Sign',
|
|
type: 'private',
|
|
optin: 'single',
|
|
tags: ['map', 'has-sign', 'automated'],
|
|
description: 'Locations with campaign signs'
|
|
});
|
|
|
|
this.lists.wantsSign = await this.ensureList({
|
|
name: 'Sign Requests',
|
|
type: 'private',
|
|
optin: 'single',
|
|
tags: ['map', 'sign-requests', 'automated'],
|
|
description: 'Supporters without signs (potential sign placements)'
|
|
});
|
|
|
|
// Create sign size lists
|
|
this.lists.signRegular = await this.ensureList({
|
|
name: 'Regular Signs',
|
|
type: 'private',
|
|
optin: 'single',
|
|
tags: ['map', 'sign-regular', 'automated'],
|
|
description: 'Locations with regular-sized campaign signs'
|
|
});
|
|
|
|
this.lists.signLarge = await this.ensureList({
|
|
name: 'Large Signs',
|
|
type: 'private',
|
|
optin: 'single',
|
|
tags: ['map', 'sign-large', 'automated'],
|
|
description: 'Locations with large-sized campaign signs'
|
|
});
|
|
|
|
this.lists.signUnsure = await this.ensureList({
|
|
name: 'Sign Size Unsure',
|
|
type: 'private',
|
|
optin: 'single',
|
|
tags: ['map', 'sign-unsure', 'automated'],
|
|
description: 'Locations with unsure sign size preferences'
|
|
});
|
|
|
|
logger.info('✅ Listmonk lists initialized successfully');
|
|
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;
|
|
}
|
|
}
|
|
|
|
// Sync a location to Listmonk
|
|
async syncLocation(locationData) {
|
|
console.log('🔍 syncLocation called with:', {
|
|
hasEmail: !!locationData.Email,
|
|
email: locationData.Email,
|
|
syncEnabled: this.syncEnabled,
|
|
firstName: locationData['First Name'],
|
|
lastName: locationData['Last Name'],
|
|
supportLevel: locationData['Support Level'],
|
|
sign: locationData.Sign,
|
|
signType: typeof locationData.Sign,
|
|
signValue: String(locationData.Sign),
|
|
signRequested: locationData['Sign Requested'],
|
|
wantsSign: locationData['Wants Sign']
|
|
});
|
|
|
|
// Let's also see all keys in the locationData to see if the field name is different
|
|
console.log('🔍 All locationData keys:', Object.keys(locationData));
|
|
console.log('🔍 Sign-related fields check:', {
|
|
'Sign': locationData.Sign,
|
|
'Sign Size': locationData['Sign Size'],
|
|
'sign': locationData.sign,
|
|
'Campaign Sign': locationData['Campaign Sign'],
|
|
'Sign Type': locationData['Sign Type'],
|
|
'Has Sign': locationData['Has Sign'],
|
|
'Yard Sign': locationData['Yard Sign']
|
|
});
|
|
|
|
if (!this.syncEnabled || !locationData.Email) {
|
|
console.log('🔍 Sync disabled or no email - returning failure');
|
|
return { success: false, error: 'Sync disabled or no email provided' };
|
|
}
|
|
|
|
try {
|
|
// Check if they have a sign - based on Sign Size field having a value
|
|
const signSize = locationData['Sign Size'];
|
|
const hasSignValue = signSize && signSize !== '' && signSize !== null && signSize !== 'null';
|
|
|
|
console.log('🔍 Sign detection:', {
|
|
'Sign Size': signSize,
|
|
'Sign Size Type': typeof signSize,
|
|
'Has Sign Value': hasSignValue,
|
|
'Sign Boolean': locationData.Sign
|
|
});
|
|
|
|
const subscriberData = {
|
|
email: locationData.Email,
|
|
name: `${locationData['First Name'] || ''} ${locationData['Last Name'] || ''}`.trim(),
|
|
status: 'enabled',
|
|
lists: [this.lists.locations.id],
|
|
attribs: {
|
|
address: locationData.Address,
|
|
support_level: locationData['Support Level'],
|
|
has_sign: locationData.Sign === true,
|
|
sign_type: signSize || null,
|
|
sign_boolean: locationData.Sign,
|
|
unit_number: locationData['Unit Number'],
|
|
phone: locationData.Phone,
|
|
notes: locationData.Notes
|
|
}
|
|
};
|
|
|
|
// Add to support level lists
|
|
const supportLevel = locationData['Support Level'];
|
|
if (supportLevel && this.lists[`supportLevel${supportLevel}`]) {
|
|
subscriberData.lists.push(this.lists[`supportLevel${supportLevel}`].id);
|
|
}
|
|
|
|
// Add to sign status lists
|
|
// 1. Has Campaign Sign = only if Sign boolean is true (they physically have a sign)
|
|
if (locationData.Sign === true) {
|
|
subscriberData.lists.push(this.lists.hasSign.id);
|
|
console.log('🔍 Added to hasSign list because Sign boolean is true');
|
|
}
|
|
|
|
// 2. Sign Requests = if they selected a sign size but Sign boolean is false (want sign, don't have it yet)
|
|
if (hasSignValue && locationData.Sign !== true) {
|
|
subscriberData.lists.push(this.lists.wantsSign.id);
|
|
console.log('🔍 Added to wantsSign list because they selected sign size but Sign boolean is false');
|
|
}
|
|
|
|
// 3. Add to specific sign size lists based on Sign Size selection
|
|
if (hasSignValue) {
|
|
const signSizeLower = signSize.toLowerCase();
|
|
if (signSizeLower === 'regular' && this.lists.signRegular) {
|
|
subscriberData.lists.push(this.lists.signRegular.id);
|
|
console.log('🔍 Added to signRegular list');
|
|
} else if (signSizeLower === 'large' && this.lists.signLarge) {
|
|
subscriberData.lists.push(this.lists.signLarge.id);
|
|
console.log('🔍 Added to signLarge list');
|
|
} else if (signSizeLower === 'unsure' && this.lists.signUnsure) {
|
|
subscriberData.lists.push(this.lists.signUnsure.id);
|
|
console.log('🔍 Added to signUnsure list');
|
|
}
|
|
}
|
|
|
|
const client = this.getClient();
|
|
|
|
// Check if subscriber already exists
|
|
try {
|
|
const { data: existingResponse } = await client.get(`/subscribers?query=${encodeURIComponent(`email = '${locationData.Email}'`)}`);
|
|
if (existingResponse.data.results && existingResponse.data.results.length > 0) {
|
|
// Update existing subscriber
|
|
const subscriberId = existingResponse.data.results[0].id;
|
|
await client.put(`/subscribers/${subscriberId}`, subscriberData);
|
|
logger.debug(`📍 Updated location subscriber: ${locationData.Email}`);
|
|
} else {
|
|
// Create new subscriber
|
|
await client.post('/subscribers', subscriberData);
|
|
logger.debug(`📍 Created location subscriber: ${locationData.Email}`);
|
|
}
|
|
console.log('🔍 Location sync successful - returning success');
|
|
return { success: true };
|
|
} catch (subscriptionError) {
|
|
console.log('🔍 Subscriber search failed, trying to create new:', subscriptionError.message);
|
|
// If subscriber doesn't exist, create new one
|
|
await client.post('/subscribers', subscriberData);
|
|
logger.debug(`📍 Created location subscriber: ${locationData.Email}`);
|
|
console.log('🔍 Location sync successful (fallback) - returning success');
|
|
return { success: true };
|
|
}
|
|
|
|
} catch (error) {
|
|
console.log('🔍 Location sync failed with error:', error.message);
|
|
logger.error(`Failed to sync location ${locationData.Email}:`, error.message);
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
// Sync a user to Listmonk
|
|
async syncUser(userData) {
|
|
if (!this.syncEnabled || !userData.Email) {
|
|
return { success: false, error: 'Sync disabled or no email provided' };
|
|
}
|
|
|
|
try {
|
|
const subscriberData = {
|
|
email: userData.Email,
|
|
name: userData.Name || userData.Email,
|
|
status: 'enabled',
|
|
lists: [this.lists.users.id],
|
|
attribs: {
|
|
user_type: userData.UserType || 'user',
|
|
admin: userData.Admin || false,
|
|
created_at: userData['Created At'] || userData.created_at,
|
|
last_login: userData['Last Login'] || userData.last_login
|
|
}
|
|
};
|
|
|
|
const client = this.getClient();
|
|
|
|
// Check if subscriber already exists
|
|
try {
|
|
const { data: existingResponse } = await client.get(`/subscribers?query=${encodeURIComponent(`email = '${userData.Email}'`)}`);
|
|
if (existingResponse.data.results && existingResponse.data.results.length > 0) {
|
|
// Update existing subscriber
|
|
const subscriberId = existingResponse.data.results[0].id;
|
|
await client.put(`/subscribers/${subscriberId}`, subscriberData);
|
|
logger.debug(`👤 Updated user subscriber: ${userData.Email}`);
|
|
} else {
|
|
// Create new subscriber
|
|
await client.post('/subscribers', subscriberData);
|
|
logger.debug(`👤 Created user subscriber: ${userData.Email}`);
|
|
}
|
|
return { success: true };
|
|
} catch (subscriptionError) {
|
|
// If subscriber doesn't exist, create new one
|
|
await client.post('/subscribers', subscriberData);
|
|
logger.debug(`👤 Created user subscriber: ${userData.Email}`);
|
|
return { success: true };
|
|
}
|
|
|
|
} catch (error) {
|
|
logger.error(`Failed to sync user ${userData.Email}:`, error.message);
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
// Remove subscriber from Listmonk
|
|
async removeSubscriber(email) {
|
|
if (!this.syncEnabled || !email) {
|
|
return { success: false, error: 'Sync disabled or no email provided' };
|
|
}
|
|
|
|
try {
|
|
const client = this.getClient();
|
|
const { data: response } = await client.get(`/subscribers?query=${encodeURIComponent(`email = '${email}'`)}`);
|
|
|
|
if (response.data.results && response.data.results.length > 0) {
|
|
const subscriberId = response.data.results[0].id;
|
|
await client.delete(`/subscribers/${subscriberId}`);
|
|
logger.debug(`🗑️ Removed subscriber: ${email}`);
|
|
return { success: true };
|
|
}
|
|
|
|
return { success: false, error: 'Subscriber not found' };
|
|
} catch (error) {
|
|
logger.error(`Failed to remove subscriber ${email}:`, error.message);
|
|
return { success: false, error: error.message };
|
|
}
|
|
}
|
|
|
|
// Bulk sync multiple records
|
|
async bulkSync(records, type) {
|
|
if (!this.syncEnabled || !records || records.length === 0) {
|
|
return { success: 0, failed: 0 };
|
|
}
|
|
|
|
let successCount = 0;
|
|
let failedCount = 0;
|
|
|
|
for (const record of records) {
|
|
try {
|
|
let result;
|
|
if (type === 'location') {
|
|
result = await this.syncLocation(record);
|
|
} else if (type === 'user') {
|
|
result = await this.syncUser(record);
|
|
}
|
|
|
|
if (result && result.success) {
|
|
successCount++;
|
|
} else {
|
|
failedCount++;
|
|
}
|
|
} catch (error) {
|
|
failedCount++;
|
|
logger.error(`Bulk sync failed for record:`, error.message);
|
|
}
|
|
}
|
|
|
|
logger.info(`📊 Bulk sync completed: ${successCount} success, ${failedCount} failed`);
|
|
return { success: successCount, failed: failedCount };
|
|
}
|
|
|
|
// Get sync status
|
|
getSyncStatus() {
|
|
return {
|
|
enabled: this.syncEnabled,
|
|
connected: this.lastError === null,
|
|
lastError: this.lastError,
|
|
lastErrorTime: this.lastErrorTime,
|
|
listsInitialized: Object.values(this.lists).some(list => list !== null)
|
|
};
|
|
}
|
|
|
|
// Get list statistics
|
|
async getListStats() {
|
|
if (!this.syncEnabled) return null;
|
|
|
|
try {
|
|
const client = this.getClient();
|
|
const { data } = await client.get('/lists');
|
|
|
|
const stats = {};
|
|
for (const [key, list] of Object.entries(this.lists)) {
|
|
if (list) {
|
|
const listData = data.data.results.find(l => l.id === list.id);
|
|
stats[key] = {
|
|
name: list.name,
|
|
subscriber_count: listData ? listData.subscriber_count : 0
|
|
};
|
|
}
|
|
}
|
|
|
|
return stats;
|
|
} catch (error) {
|
|
logger.error('Failed to get list stats:', error.message);
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Export singleton instance
|
|
module.exports = new ListmonkService();
|