freealberta/map/app/services/listmonk.js
2025-08-15 11:14:38 -06:00

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();