Initial v2 commit: complete rebuild with unified API + React admin
Phase 1-14 complete: - Unified Express.js API (TypeScript, Prisma ORM, PostgreSQL 16) - React Admin GUI (Vite + Ant Design + Zustand) - JWT auth with refresh tokens - Influence: Campaigns, Representatives, Responses, Email Queue - Map: Locations, Cuts, Shifts, Canvassing System - NAR data import infrastructure (2025 format) - Listmonk newsletter integration - Landing page builder (GrapesJS) - MkDocs + Code Server integration - Volunteer portal with GPS tracking - Monitoring stack (Prometheus, Grafana, Alertmanager) - Pangolin tunnel integration Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
470
influence/app/services/email.js
Normal file
470
influence/app/services/email.js
Normal file
@@ -0,0 +1,470 @@
|
||||
const nodemailer = require('nodemailer');
|
||||
const emailTemplates = require('./emailTemplates');
|
||||
|
||||
class EmailService {
|
||||
constructor() {
|
||||
this.transporter = null;
|
||||
this.initializeTransporter();
|
||||
}
|
||||
|
||||
initializeTransporter() {
|
||||
try {
|
||||
const transporterConfig = {
|
||||
host: process.env.SMTP_HOST,
|
||||
port: parseInt(process.env.SMTP_PORT) || 587,
|
||||
secure: process.env.SMTP_PORT === '465', // true for 465, false for other ports
|
||||
tls: {
|
||||
rejectUnauthorized: false
|
||||
}
|
||||
};
|
||||
|
||||
// Add auth if credentials are provided
|
||||
if (process.env.SMTP_USER && process.env.SMTP_PASS) {
|
||||
transporterConfig.auth = {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS
|
||||
};
|
||||
}
|
||||
|
||||
this.transporter = nodemailer.createTransport(transporterConfig);
|
||||
|
||||
console.log('Email transporter initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize email transporter:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async testConnection() {
|
||||
try {
|
||||
if (!this.transporter) {
|
||||
throw new Error('Email transporter not initialized');
|
||||
}
|
||||
|
||||
await this.transporter.verify();
|
||||
return {
|
||||
success: true,
|
||||
message: 'SMTP connection verified successfully'
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'SMTP connection failed',
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async sendEmail(emailOptions, isTest = false) {
|
||||
try {
|
||||
if (!this.transporter) {
|
||||
throw new Error('Email transporter not initialized');
|
||||
}
|
||||
|
||||
let to = emailOptions.to;
|
||||
let subject = emailOptions.subject;
|
||||
|
||||
// Test mode - redirect emails and modify subject
|
||||
const testMode = isTest || process.env.EMAIL_TEST_MODE === 'true';
|
||||
if (testMode) {
|
||||
const originalTo = to;
|
||||
to = process.env.TEST_EMAIL_RECIPIENT || 'admin@example.com';
|
||||
subject = `[TEST - Original: ${originalTo}] ${subject}`;
|
||||
|
||||
console.log(`Email redirected from ${originalTo} to ${to} (Test Mode)`);
|
||||
}
|
||||
|
||||
const mailOptions = {
|
||||
from: `"${emailOptions.from.name}" <${emailOptions.from.email}>`,
|
||||
to: to,
|
||||
replyTo: emailOptions.replyTo,
|
||||
subject: subject,
|
||||
text: emailOptions.text,
|
||||
html: emailOptions.html
|
||||
};
|
||||
|
||||
// Log email details in development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('Email Preview:', {
|
||||
to: mailOptions.to,
|
||||
subject: mailOptions.subject,
|
||||
preview: emailOptions.text ? emailOptions.text.substring(0, 200) + '...' : 'No text content',
|
||||
testMode: testMode
|
||||
});
|
||||
}
|
||||
|
||||
console.log('DEBUG: About to send email via SMTP...');
|
||||
const info = await this.transporter.sendMail(mailOptions);
|
||||
|
||||
console.log('DEBUG: Email sent via SMTP successfully:', info.messageId);
|
||||
console.log('DEBUG: Email info response:', info.response);
|
||||
|
||||
// Log email to database if NocoDB service is available
|
||||
console.log('DEBUG: About to log email to database...');
|
||||
try {
|
||||
await this.logEmailSent({
|
||||
to: emailOptions.to, // Log original recipient
|
||||
subject: emailOptions.subject, // Log original subject
|
||||
status: 'sent',
|
||||
messageId: info.messageId,
|
||||
testMode: testMode,
|
||||
senderName: emailOptions.from?.name || 'System',
|
||||
senderEmail: emailOptions.from?.email || process.env.SMTP_FROM_EMAIL
|
||||
});
|
||||
console.log('DEBUG: Successfully logged email to database');
|
||||
} catch (logError) {
|
||||
console.error('DEBUG: Failed to log email to database:', logError);
|
||||
// Continue anyway - don't let logging failure affect email success
|
||||
}
|
||||
|
||||
const successResult = {
|
||||
success: true,
|
||||
messageId: info.messageId,
|
||||
response: info.response,
|
||||
testMode: testMode,
|
||||
originalRecipient: testMode ? emailOptions.to : undefined
|
||||
};
|
||||
|
||||
console.log('DEBUG: Returning success result:', successResult);
|
||||
return successResult;
|
||||
} catch (error) {
|
||||
console.error('Email send error:', error);
|
||||
|
||||
// Log failed email attempt
|
||||
await this.logEmailSent({
|
||||
to: emailOptions.to,
|
||||
subject: emailOptions.subject,
|
||||
status: 'failed',
|
||||
error: error.message,
|
||||
testMode: isTest || process.env.EMAIL_TEST_MODE === 'true',
|
||||
senderName: emailOptions.from?.name || 'System',
|
||||
senderEmail: emailOptions.from?.email || process.env.SMTP_FROM_EMAIL
|
||||
});
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async sendBulkEmails(emails) {
|
||||
const results = [];
|
||||
|
||||
for (const email of emails) {
|
||||
try {
|
||||
const result = await this.sendEmail(email);
|
||||
results.push({
|
||||
to: email.to,
|
||||
success: result.success,
|
||||
messageId: result.messageId,
|
||||
error: result.error
|
||||
});
|
||||
|
||||
// Add a small delay between emails to avoid overwhelming the SMTP server
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
} catch (error) {
|
||||
results.push({
|
||||
to: email.to,
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
formatEmailTemplate(template, data) {
|
||||
let formattedTemplate = template;
|
||||
|
||||
// Replace placeholders with actual data
|
||||
Object.keys(data).forEach(key => {
|
||||
const placeholder = `{{${key}}}`;
|
||||
formattedTemplate = formattedTemplate.replace(new RegExp(placeholder, 'g'), data[key]);
|
||||
});
|
||||
|
||||
return formattedTemplate;
|
||||
}
|
||||
|
||||
validateEmailAddress(email) {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
|
||||
async logEmailSent(emailData) {
|
||||
try {
|
||||
// Use the existing logEmailSend method with the correct field structure
|
||||
const nocodbService = require('./nocodb');
|
||||
if (nocodbService && process.env.NOCODB_TABLE_EMAILS) {
|
||||
await nocodbService.logEmailSend({
|
||||
recipientEmail: emailData.to,
|
||||
senderName: emailData.senderName || 'System',
|
||||
senderEmail: emailData.senderEmail || process.env.SMTP_FROM_EMAIL,
|
||||
subject: emailData.subject,
|
||||
postalCode: emailData.postalCode || 'N/A',
|
||||
status: emailData.status || 'sent',
|
||||
timestamp: new Date().toISOString(),
|
||||
senderIP: emailData.senderIP || 'localhost'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to log email:', error);
|
||||
// Don't throw - logging failure shouldn't prevent email sending
|
||||
}
|
||||
}
|
||||
|
||||
async previewEmail(emailOptions) {
|
||||
// Generate email preview without sending
|
||||
return {
|
||||
to: emailOptions.to,
|
||||
subject: emailOptions.subject,
|
||||
body: emailOptions.text,
|
||||
html: emailOptions.html,
|
||||
from: `"${emailOptions.from.name}" <${emailOptions.from.email}>`,
|
||||
replyTo: emailOptions.replyTo,
|
||||
timestamp: new Date().toISOString(),
|
||||
testMode: process.env.EMAIL_TEST_MODE === 'true',
|
||||
redirectTo: process.env.EMAIL_TEST_MODE === 'true' ? process.env.TEST_EMAIL_RECIPIENT : null
|
||||
};
|
||||
}
|
||||
|
||||
// Template-based email methods
|
||||
async sendTemplatedEmail(templateName, templateVariables, emailOptions, isTest = false) {
|
||||
console.log('DEBUG: sendTemplatedEmail called with template:', templateName);
|
||||
try {
|
||||
// Render the template
|
||||
console.log('DEBUG: About to render template...');
|
||||
const { html, text } = await emailTemplates.render(templateName, templateVariables);
|
||||
console.log('DEBUG: Template rendered successfully');
|
||||
|
||||
// Prepare email options with rendered content
|
||||
const mailOptions = {
|
||||
...emailOptions,
|
||||
text: text,
|
||||
html: html
|
||||
};
|
||||
|
||||
// Send the email using existing sendEmail method
|
||||
console.log('DEBUG: About to call sendEmail from sendTemplatedEmail...');
|
||||
const result = await this.sendEmail(mailOptions, isTest);
|
||||
console.log('DEBUG: sendEmail returned result:', result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('DEBUG: Failed to send templated email:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async sendRepresentativeEmail(recipientEmail, senderName, senderEmail, subject, message, postalCode, recipientName = null) {
|
||||
// Generate dynamic subject if not provided
|
||||
const finalSubject = subject || `Message from ${senderName} from ${postalCode}`;
|
||||
|
||||
const templateVariables = {
|
||||
MESSAGE: message,
|
||||
SENDER_NAME: senderName,
|
||||
SENDER_EMAIL: senderEmail,
|
||||
POSTAL_CODE: postalCode,
|
||||
RECIPIENT_NAME: recipientName || 'Representative'
|
||||
};
|
||||
|
||||
const emailOptions = {
|
||||
to: recipientEmail,
|
||||
from: {
|
||||
email: process.env.SMTP_FROM_EMAIL,
|
||||
name: process.env.SMTP_FROM_NAME
|
||||
},
|
||||
replyTo: senderEmail,
|
||||
subject: finalSubject
|
||||
};
|
||||
|
||||
return await this.sendTemplatedEmail('representative-contact', templateVariables, emailOptions);
|
||||
}
|
||||
|
||||
async sendCampaignEmail(recipientEmail, userEmail, userName, postalCode, subject, message, campaignTitle, recipientName = null, recipientLevel = null) {
|
||||
console.log('DEBUG: sendCampaignEmail called for recipient:', recipientEmail);
|
||||
const templateVariables = {
|
||||
MESSAGE: message,
|
||||
USER_NAME: userName,
|
||||
USER_EMAIL: userEmail,
|
||||
POSTAL_CODE: postalCode,
|
||||
CAMPAIGN_TITLE: campaignTitle,
|
||||
RECIPIENT_NAME: recipientName,
|
||||
RECIPIENT_LEVEL: recipientLevel
|
||||
};
|
||||
|
||||
const emailOptions = {
|
||||
to: recipientEmail,
|
||||
from: {
|
||||
email: process.env.SMTP_FROM_EMAIL,
|
||||
name: process.env.SMTP_FROM_NAME
|
||||
},
|
||||
replyTo: userEmail,
|
||||
subject: subject
|
||||
};
|
||||
|
||||
console.log('DEBUG: About to call sendTemplatedEmail from sendCampaignEmail...');
|
||||
const result = await this.sendTemplatedEmail('campaign-email', templateVariables, emailOptions);
|
||||
console.log('DEBUG: sendCampaignEmail received result:', result);
|
||||
return result;
|
||||
}
|
||||
|
||||
async sendTestEmail(subject, message, testRecipient = null) {
|
||||
const recipient = testRecipient || process.env.TEST_EMAIL_RECIPIENT || 'admin@example.com';
|
||||
|
||||
const templateVariables = {
|
||||
MESSAGE: message
|
||||
};
|
||||
|
||||
const emailOptions = {
|
||||
to: recipient,
|
||||
from: {
|
||||
email: process.env.SMTP_FROM_EMAIL,
|
||||
name: process.env.SMTP_FROM_NAME
|
||||
},
|
||||
replyTo: process.env.SMTP_FROM_EMAIL,
|
||||
subject: `[TEST EMAIL] ${subject}`
|
||||
};
|
||||
|
||||
return await this.sendTemplatedEmail('test-email', templateVariables, emailOptions, true);
|
||||
}
|
||||
|
||||
async previewTemplatedEmail(templateName, templateVariables, emailOptions) {
|
||||
try {
|
||||
const { html, text } = await emailTemplates.render(templateName, templateVariables);
|
||||
|
||||
return {
|
||||
to: emailOptions.to,
|
||||
subject: emailOptions.subject,
|
||||
body: text,
|
||||
html: html,
|
||||
from: `"${emailOptions.from.name}" <${emailOptions.from.email}>`,
|
||||
replyTo: emailOptions.replyTo,
|
||||
timestamp: new Date().toISOString(),
|
||||
testMode: process.env.EMAIL_TEST_MODE === 'true',
|
||||
redirectTo: process.env.EMAIL_TEST_MODE === 'true' ? process.env.TEST_EMAIL_RECIPIENT : null,
|
||||
templateName: templateName,
|
||||
templateVariables: templateVariables
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to preview templated email:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// User management email methods
|
||||
async sendLoginDetails(user) {
|
||||
try {
|
||||
const baseUrl = process.env.BASE_URL || `http://localhost:${process.env.PORT || 3333}`;
|
||||
|
||||
const isAdmin = user.admin || user.Admin || false;
|
||||
|
||||
const templateVariables = {
|
||||
APP_NAME: 'BNKops Influence',
|
||||
USER_NAME: user.Name || user.name || user.Email || user.email,
|
||||
USER_EMAIL: user.Email || user.email,
|
||||
PASSWORD: user.Password || user.password,
|
||||
USER_ROLE: isAdmin ? 'Administrator' : 'User',
|
||||
LOGIN_URL: `${baseUrl}/login.html`,
|
||||
TIMESTAMP: new Date().toLocaleString()
|
||||
};
|
||||
|
||||
const emailOptions = {
|
||||
to: user.Email || user.email,
|
||||
from: {
|
||||
email: process.env.SMTP_FROM_EMAIL,
|
||||
name: process.env.SMTP_FROM_NAME
|
||||
},
|
||||
subject: `Your Login Details - ${templateVariables.APP_NAME}`
|
||||
};
|
||||
|
||||
return await this.sendTemplatedEmail('login-details', templateVariables, emailOptions);
|
||||
} catch (error) {
|
||||
console.error('Failed to send login details email:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async sendEmailVerification(recipientEmail, verificationUrl, userName) {
|
||||
try {
|
||||
const templateVariables = {
|
||||
USER_NAME: userName || 'there',
|
||||
VERIFICATION_URL: verificationUrl
|
||||
};
|
||||
|
||||
const emailOptions = {
|
||||
to: recipientEmail,
|
||||
from: {
|
||||
email: process.env.SMTP_FROM_EMAIL,
|
||||
name: process.env.SMTP_FROM_NAME
|
||||
},
|
||||
subject: 'Verify Your Email to Create Your Campaign'
|
||||
};
|
||||
|
||||
return await this.sendTemplatedEmail('email-verification', templateVariables, emailOptions);
|
||||
} catch (error) {
|
||||
console.error('Failed to send email verification:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send response verification email to representative
|
||||
* @param {Object} options - Email options
|
||||
* @param {string} options.representativeEmail - Representative's email address
|
||||
* @param {string} options.representativeName - Representative's name
|
||||
* @param {string} options.campaignTitle - Campaign title
|
||||
* @param {string} options.responseType - Type of response (Email, Letter, etc.)
|
||||
* @param {string} options.responseText - The actual response text
|
||||
* @param {string} options.submittedDate - Date the response was submitted
|
||||
* @param {string} options.submitterName - Name of person who submitted
|
||||
* @param {string} options.verificationUrl - URL to verify the response
|
||||
* @param {string} options.reportUrl - URL to report as invalid
|
||||
*/
|
||||
async sendResponseVerification(options) {
|
||||
try {
|
||||
const {
|
||||
representativeEmail,
|
||||
representativeName,
|
||||
campaignTitle,
|
||||
responseType,
|
||||
responseText,
|
||||
submittedDate,
|
||||
submitterName,
|
||||
verificationUrl,
|
||||
reportUrl
|
||||
} = options;
|
||||
|
||||
const templateVariables = {
|
||||
REPRESENTATIVE_NAME: representativeName,
|
||||
CAMPAIGN_TITLE: campaignTitle,
|
||||
RESPONSE_TYPE: responseType,
|
||||
RESPONSE_TEXT: responseText,
|
||||
SUBMITTED_DATE: submittedDate,
|
||||
SUBMITTER_NAME: submitterName || 'Anonymous',
|
||||
VERIFICATION_URL: verificationUrl,
|
||||
REPORT_URL: reportUrl,
|
||||
APP_NAME: process.env.APP_NAME || 'BNKops Influence',
|
||||
TIMESTAMP: new Date().toLocaleString()
|
||||
};
|
||||
|
||||
const emailOptions = {
|
||||
to: representativeEmail,
|
||||
from: {
|
||||
email: process.env.SMTP_FROM_EMAIL,
|
||||
name: process.env.SMTP_FROM_NAME
|
||||
},
|
||||
subject: `Response Verification Request - ${campaignTitle}`
|
||||
};
|
||||
|
||||
return await this.sendTemplatedEmail('response-verification', templateVariables, emailOptions);
|
||||
} catch (error) {
|
||||
console.error('Failed to send response verification email:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new EmailService();
|
||||
368
influence/app/services/emailQueue.js
Normal file
368
influence/app/services/emailQueue.js
Normal file
@@ -0,0 +1,368 @@
|
||||
const Queue = require('bull');
|
||||
const logger = require('./logger');
|
||||
const metrics = require('./metrics');
|
||||
const emailService = require('../services/email');
|
||||
|
||||
// Configure Redis connection for Bull
|
||||
const redisConfig = {
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
port: parseInt(process.env.REDIS_PORT || '6379'),
|
||||
password: process.env.REDIS_PASSWORD || undefined,
|
||||
db: parseInt(process.env.REDIS_DB || '0'),
|
||||
// Retry strategy for connection failures
|
||||
retryStrategy: (times) => {
|
||||
const delay = Math.min(times * 50, 2000);
|
||||
return delay;
|
||||
},
|
||||
// Enable offline queue
|
||||
enableOfflineQueue: true,
|
||||
maxRetriesPerRequest: 3
|
||||
};
|
||||
|
||||
// Create email queue
|
||||
const emailQueue = new Queue('email-queue', {
|
||||
redis: redisConfig,
|
||||
defaultJobOptions: {
|
||||
attempts: 3,
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 2000 // Start with 2 seconds, then 4, 8, etc.
|
||||
},
|
||||
removeOnComplete: {
|
||||
age: 24 * 3600, // Keep completed jobs for 24 hours
|
||||
count: 1000 // Keep last 1000 completed jobs
|
||||
},
|
||||
removeOnFail: {
|
||||
age: 7 * 24 * 3600 // Keep failed jobs for 7 days
|
||||
},
|
||||
timeout: 30000 // 30 seconds timeout per job
|
||||
}
|
||||
});
|
||||
|
||||
// Process email jobs
|
||||
emailQueue.process(async (job) => {
|
||||
const { type, data } = job.data;
|
||||
const start = Date.now();
|
||||
|
||||
logger.info('Processing email job', {
|
||||
jobId: job.id,
|
||||
type,
|
||||
attempt: job.attemptsMade + 1,
|
||||
maxAttempts: job.opts.attempts
|
||||
});
|
||||
|
||||
try {
|
||||
let result;
|
||||
|
||||
switch (type) {
|
||||
case 'campaign':
|
||||
result = await emailService.sendCampaignEmail(data);
|
||||
break;
|
||||
|
||||
case 'verification':
|
||||
result = await emailService.sendVerificationEmail(data);
|
||||
break;
|
||||
|
||||
case 'login-details':
|
||||
result = await emailService.sendLoginDetails(data);
|
||||
break;
|
||||
|
||||
case 'broadcast':
|
||||
result = await emailService.sendBroadcast(data);
|
||||
break;
|
||||
|
||||
case 'response-verification':
|
||||
result = await emailService.sendResponseVerificationEmail(data);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown email type: ${type}`);
|
||||
}
|
||||
|
||||
const duration = (Date.now() - start) / 1000;
|
||||
|
||||
// Record metrics
|
||||
metrics.recordEmailSent(
|
||||
data.campaignId || 'system',
|
||||
data.representativeLevel || 'unknown'
|
||||
);
|
||||
metrics.observeEmailSendDuration(
|
||||
data.campaignId || 'system',
|
||||
duration
|
||||
);
|
||||
|
||||
logger.logEmailSent(
|
||||
data.to || data.email,
|
||||
data.campaignId || type,
|
||||
'success'
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
const duration = (Date.now() - start) / 1000;
|
||||
|
||||
// Record failure metrics
|
||||
metrics.recordEmailFailed(
|
||||
data.campaignId || 'system',
|
||||
error.code || 'unknown'
|
||||
);
|
||||
|
||||
logger.logEmailFailed(
|
||||
data.to || data.email,
|
||||
data.campaignId || type,
|
||||
error
|
||||
);
|
||||
|
||||
// Throw error to trigger retry
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
// Queue event handlers
|
||||
emailQueue.on('completed', (job, result) => {
|
||||
logger.info('Email job completed', {
|
||||
jobId: job.id,
|
||||
type: job.data.type,
|
||||
duration: Date.now() - job.timestamp
|
||||
});
|
||||
});
|
||||
|
||||
emailQueue.on('failed', (job, err) => {
|
||||
logger.error('Email job failed', {
|
||||
jobId: job.id,
|
||||
type: job.data.type,
|
||||
attempt: job.attemptsMade,
|
||||
maxAttempts: job.opts.attempts,
|
||||
error: err.message,
|
||||
willRetry: job.attemptsMade < job.opts.attempts
|
||||
});
|
||||
});
|
||||
|
||||
emailQueue.on('stalled', (job) => {
|
||||
logger.warn('Email job stalled', {
|
||||
jobId: job.id,
|
||||
type: job.data.type
|
||||
});
|
||||
});
|
||||
|
||||
emailQueue.on('error', (error) => {
|
||||
logger.error('Email queue error', { error: error.message });
|
||||
});
|
||||
|
||||
// Update queue size metric every 10 seconds
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const counts = await emailQueue.getJobCounts();
|
||||
const queueSize = counts.waiting + counts.active;
|
||||
metrics.setEmailQueueSize(queueSize);
|
||||
} catch (error) {
|
||||
logger.warn('Failed to update queue metrics', { error: error.message });
|
||||
}
|
||||
}, 10000);
|
||||
|
||||
/**
|
||||
* Email Queue Service
|
||||
* Provides methods to enqueue different types of emails
|
||||
*/
|
||||
class EmailQueueService {
|
||||
/**
|
||||
* Send campaign email (to representative)
|
||||
*/
|
||||
async sendCampaignEmail(emailData) {
|
||||
const job = await emailQueue.add(
|
||||
{
|
||||
type: 'campaign',
|
||||
data: emailData
|
||||
},
|
||||
{
|
||||
priority: 2, // Normal priority
|
||||
jobId: `campaign-${emailData.campaignId}-${Date.now()}`
|
||||
}
|
||||
);
|
||||
|
||||
logger.info('Campaign email queued', {
|
||||
jobId: job.id,
|
||||
campaignId: emailData.campaignId,
|
||||
recipient: emailData.to
|
||||
});
|
||||
|
||||
return { jobId: job.id, queued: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Send email verification
|
||||
*/
|
||||
async sendVerificationEmail(emailData) {
|
||||
const job = await emailQueue.add(
|
||||
{
|
||||
type: 'verification',
|
||||
data: emailData
|
||||
},
|
||||
{
|
||||
priority: 1, // High priority - user waiting
|
||||
jobId: `verification-${emailData.email}-${Date.now()}`
|
||||
}
|
||||
);
|
||||
|
||||
logger.info('Verification email queued', {
|
||||
jobId: job.id,
|
||||
email: emailData.email
|
||||
});
|
||||
|
||||
return { jobId: job.id, queued: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Send login details
|
||||
*/
|
||||
async sendLoginDetails(emailData) {
|
||||
const job = await emailQueue.add(
|
||||
{
|
||||
type: 'login-details',
|
||||
data: emailData
|
||||
},
|
||||
{
|
||||
priority: 1, // High priority
|
||||
jobId: `login-${emailData.email}-${Date.now()}`
|
||||
}
|
||||
);
|
||||
|
||||
logger.info('Login details email queued', {
|
||||
jobId: job.id,
|
||||
email: emailData.email
|
||||
});
|
||||
|
||||
return { jobId: job.id, queued: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Send broadcast to users
|
||||
*/
|
||||
async sendBroadcast(emailData) {
|
||||
const job = await emailQueue.add(
|
||||
{
|
||||
type: 'broadcast',
|
||||
data: emailData
|
||||
},
|
||||
{
|
||||
priority: 3, // Lower priority - batch operation
|
||||
jobId: `broadcast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
}
|
||||
);
|
||||
|
||||
logger.info('Broadcast email queued', {
|
||||
jobId: job.id,
|
||||
recipientCount: emailData.recipients?.length || 1
|
||||
});
|
||||
|
||||
return { jobId: job.id, queued: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Send response verification email
|
||||
*/
|
||||
async sendResponseVerificationEmail(emailData) {
|
||||
const job = await emailQueue.add(
|
||||
{
|
||||
type: 'response-verification',
|
||||
data: emailData
|
||||
},
|
||||
{
|
||||
priority: 2,
|
||||
jobId: `response-verification-${emailData.email}-${Date.now()}`
|
||||
}
|
||||
);
|
||||
|
||||
logger.info('Response verification email queued', {
|
||||
jobId: job.id,
|
||||
email: emailData.email
|
||||
});
|
||||
|
||||
return { jobId: job.id, queued: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get job status
|
||||
*/
|
||||
async getJobStatus(jobId) {
|
||||
const job = await emailQueue.getJob(jobId);
|
||||
|
||||
if (!job) {
|
||||
return { status: 'not_found' };
|
||||
}
|
||||
|
||||
const state = await job.getState();
|
||||
const progress = job.progress();
|
||||
|
||||
return {
|
||||
jobId: job.id,
|
||||
status: state,
|
||||
progress,
|
||||
attempts: job.attemptsMade,
|
||||
data: job.data,
|
||||
createdAt: job.timestamp,
|
||||
processedAt: job.processedOn,
|
||||
finishedAt: job.finishedOn,
|
||||
failedReason: job.failedReason
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue statistics
|
||||
*/
|
||||
async getQueueStats() {
|
||||
const counts = await emailQueue.getJobCounts();
|
||||
const jobs = {
|
||||
waiting: await emailQueue.getWaiting(0, 10),
|
||||
active: await emailQueue.getActive(0, 10),
|
||||
completed: await emailQueue.getCompleted(0, 10),
|
||||
failed: await emailQueue.getFailed(0, 10)
|
||||
};
|
||||
|
||||
return {
|
||||
counts,
|
||||
samples: {
|
||||
waiting: jobs.waiting.map(j => ({ id: j.id, type: j.data.type })),
|
||||
active: jobs.active.map(j => ({ id: j.id, type: j.data.type })),
|
||||
completed: jobs.completed.slice(0, 5).map(j => ({ id: j.id, type: j.data.type })),
|
||||
failed: jobs.failed.slice(0, 5).map(j => ({ id: j.id, type: j.data.type, reason: j.failedReason }))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean old jobs
|
||||
*/
|
||||
async cleanQueue(grace = 24 * 3600 * 1000) {
|
||||
const cleaned = await emailQueue.clean(grace, 'completed');
|
||||
logger.info('Queue cleaned', { removedJobs: cleaned.length });
|
||||
return { cleaned: cleaned.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause queue
|
||||
*/
|
||||
async pauseQueue() {
|
||||
await emailQueue.pause();
|
||||
logger.warn('Email queue paused');
|
||||
return { paused: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume queue
|
||||
*/
|
||||
async resumeQueue() {
|
||||
await emailQueue.resume();
|
||||
logger.info('Email queue resumed');
|
||||
return { resumed: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get queue instance (for advanced operations)
|
||||
*/
|
||||
getQueue() {
|
||||
return emailQueue;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new EmailQueueService();
|
||||
135
influence/app/services/emailTemplates.js
Normal file
135
influence/app/services/emailTemplates.js
Normal file
@@ -0,0 +1,135 @@
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
|
||||
class EmailTemplateService {
|
||||
constructor() {
|
||||
this.templatesDir = path.join(__dirname, '../templates/email');
|
||||
this.cache = new Map();
|
||||
}
|
||||
|
||||
async loadTemplate(templateName, type = 'html') {
|
||||
const cacheKey = `${templateName}.${type}`;
|
||||
|
||||
// Check cache first
|
||||
if (this.cache.has(cacheKey)) {
|
||||
return this.cache.get(cacheKey);
|
||||
}
|
||||
|
||||
try {
|
||||
const templatePath = path.join(this.templatesDir, `${templateName}.${type}`);
|
||||
const template = await fs.readFile(templatePath, 'utf-8');
|
||||
|
||||
// Cache the template
|
||||
this.cache.set(cacheKey, template);
|
||||
|
||||
return template;
|
||||
} catch (error) {
|
||||
console.error(`Failed to load email template ${templateName}.${type}:`, error);
|
||||
throw new Error(`Email template not found: ${templateName}.${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
processTemplate(template, variables) {
|
||||
if (!template) return '';
|
||||
|
||||
let processed = template;
|
||||
|
||||
// Handle conditional blocks {{#if VARIABLE}}...{{/if}}
|
||||
processed = processed.replace(/\{\{#if\s+(\w+)\}\}([\s\S]*?)\{\{\/if\}\}/g, (match, varName, content) => {
|
||||
const value = variables[varName];
|
||||
// Check if value exists and is not empty string
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
// Recursively process the content inside the conditional block
|
||||
return this.processTemplate(content, variables);
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
// Replace variables {{VARIABLE}}
|
||||
processed = processed.replace(/\{\{(\w+)\}\}/g, (match, varName) => {
|
||||
const value = variables[varName];
|
||||
// Return the value or empty string if undefined
|
||||
return value !== undefined && value !== null ? String(value) : '';
|
||||
});
|
||||
|
||||
// Handle line breaks in MESSAGE field for HTML templates
|
||||
if (variables.MESSAGE && processed.includes('{{MESSAGE}}')) {
|
||||
processed = processed.replace(/\{\{MESSAGE\}\}/g, variables.MESSAGE.replace(/\n/g, '<br>'));
|
||||
}
|
||||
|
||||
return processed;
|
||||
}
|
||||
|
||||
async render(templateName, variables) {
|
||||
try {
|
||||
// Load both HTML and text versions
|
||||
const [htmlTemplate, textTemplate] = await Promise.all([
|
||||
this.loadTemplate(templateName, 'html'),
|
||||
this.loadTemplate(templateName, 'txt')
|
||||
]);
|
||||
|
||||
// Add default variables
|
||||
const defaultVariables = {
|
||||
APP_NAME: process.env.APP_NAME || 'BNKops Influence Campaign',
|
||||
TIMESTAMP: new Date().toLocaleString(),
|
||||
...variables
|
||||
};
|
||||
|
||||
// Use processTemplate which handles conditionals properly
|
||||
return {
|
||||
html: this.processTemplate(htmlTemplate, defaultVariables),
|
||||
text: this.processTemplate(textTemplate, defaultVariables)
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to render email template:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Get available template names
|
||||
async getAvailableTemplates() {
|
||||
try {
|
||||
const files = await fs.readdir(this.templatesDir);
|
||||
const templates = new Set();
|
||||
|
||||
files.forEach(file => {
|
||||
const ext = path.extname(file);
|
||||
const name = path.basename(file, ext);
|
||||
if (ext === '.html' || ext === '.txt') {
|
||||
templates.add(name);
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(templates);
|
||||
} catch (error) {
|
||||
console.error('Failed to get available templates:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Clear template cache (useful for development)
|
||||
clearCache() {
|
||||
this.cache.clear();
|
||||
console.log('Email template cache cleared');
|
||||
}
|
||||
|
||||
// Check if template exists
|
||||
async templateExists(templateName) {
|
||||
try {
|
||||
const htmlPath = path.join(this.templatesDir, `${templateName}.html`);
|
||||
const txtPath = path.join(this.templatesDir, `${templateName}.txt`);
|
||||
|
||||
// Check if at least one format exists
|
||||
const [htmlExists, txtExists] = await Promise.all([
|
||||
fs.access(htmlPath).then(() => true).catch(() => false),
|
||||
fs.access(txtPath).then(() => true).catch(() => false)
|
||||
]);
|
||||
|
||||
return htmlExists || txtExists;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new EmailTemplateService();
|
||||
832
influence/app/services/listmonk.js
Normal file
832
influence/app/services/listmonk.js
Normal file
@@ -0,0 +1,832 @@
|
||||
const axios = require('axios');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
class ListmonkService {
|
||||
constructor() {
|
||||
this.baseURL = process.env.LISTMONK_API_URL || 'http://listmonk:9000/api';
|
||||
this.username = process.env.LISTMONK_USERNAME;
|
||||
this.password = process.env.LISTMONK_PASSWORD;
|
||||
this.lists = {
|
||||
allCampaigns: null,
|
||||
activeCampaigns: null,
|
||||
customRecipients: null,
|
||||
campaignParticipants: null,
|
||||
emailLogs: null, // For generic email logs (non-campaign)
|
||||
campaignLists: {} // Dynamic per-campaign lists
|
||||
};
|
||||
|
||||
// Debug logging for environment variables
|
||||
console.log('🔍 Listmonk Environment Variables (Influence):');
|
||||
console.log(` LISTMONK_SYNC_ENABLED: ${process.env.LISTMONK_SYNC_ENABLED}`);
|
||||
console.log(` LISTMONK_INITIAL_SYNC: ${process.env.LISTMONK_INITIAL_SYNC}`);
|
||||
console.log(` LISTMONK_API_URL: ${process.env.LISTMONK_API_URL}`);
|
||||
console.log(` LISTMONK_USERNAME: ${this.username ? 'SET' : 'NOT SET'}`);
|
||||
console.log(` LISTMONK_PASSWORD: ${this.password ? 'SET' : 'NOT SET'}`);
|
||||
|
||||
this.syncEnabled = process.env.LISTMONK_SYNC_ENABLED === 'true';
|
||||
|
||||
// Additional validation - disable if credentials are missing
|
||||
if (this.syncEnabled && (!this.username || !this.password)) {
|
||||
logger.warn('Listmonk credentials missing - disabling sync');
|
||||
this.syncEnabled = false;
|
||||
}
|
||||
|
||||
console.log(` Final syncEnabled: ${this.syncEnabled}`);
|
||||
|
||||
this.lastError = null;
|
||||
this.lastErrorTime = null;
|
||||
}
|
||||
|
||||
// Validate and clean email address
|
||||
validateAndCleanEmail(email) {
|
||||
if (!email || typeof email !== 'string') {
|
||||
return { valid: false, cleaned: null, error: 'Email is required' };
|
||||
}
|
||||
|
||||
// Trim whitespace and convert to lowercase
|
||||
let cleaned = email.trim().toLowerCase();
|
||||
|
||||
// Basic email format validation
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(cleaned)) {
|
||||
return { valid: false, cleaned: null, error: 'Invalid email format' };
|
||||
}
|
||||
|
||||
// Check for common typos in domain extensions
|
||||
const commonTypos = {
|
||||
'.co': '.ca',
|
||||
'.cA': '.ca',
|
||||
'.Ca': '.ca',
|
||||
'.cOM': '.com',
|
||||
'.coM': '.com',
|
||||
'.cOm': '.com',
|
||||
'.neT': '.net',
|
||||
'.nEt': '.net',
|
||||
'.ORg': '.org',
|
||||
'.oRg': '.org'
|
||||
};
|
||||
|
||||
// Fix common domain extension typos
|
||||
for (const [typo, correction] of Object.entries(commonTypos)) {
|
||||
if (cleaned.endsWith(typo)) {
|
||||
const fixedEmail = cleaned.slice(0, -typo.length) + correction;
|
||||
logger.warn(`Email validation: Fixed typo in ${email} -> ${fixedEmail}`);
|
||||
cleaned = fixedEmail;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Additional validation: check for suspicious patterns
|
||||
if (cleaned.includes('..') || cleaned.startsWith('.') || cleaned.endsWith('.')) {
|
||||
return { valid: false, cleaned: null, error: 'Invalid email pattern' };
|
||||
}
|
||||
|
||||
return { valid: true, cleaned, error: null };
|
||||
}
|
||||
|
||||
// Create axios instance with auth
|
||||
getClient() {
|
||||
return axios.create({
|
||||
baseURL: this.baseURL,
|
||||
auth: {
|
||||
username: this.username,
|
||||
password: this.password
|
||||
},
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
timeout: 10000 // 10 second timeout
|
||||
});
|
||||
}
|
||||
|
||||
// Test connection to Listmonk
|
||||
async checkConnection() {
|
||||
if (!this.syncEnabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`🔍 Testing connection to: ${this.baseURL}`);
|
||||
console.log(`🔍 Using credentials: ${this.username}:${this.password ? 'SET' : 'NOT SET'}`);
|
||||
|
||||
const client = this.getClient();
|
||||
console.log('🔍 Making request to /health endpoint...');
|
||||
const { data } = await client.get('/health');
|
||||
|
||||
console.log('🔍 Response received:', JSON.stringify(data, null, 2));
|
||||
|
||||
if (data.data === true) {
|
||||
logger.info('Listmonk connection successful');
|
||||
this.lastError = null;
|
||||
this.lastErrorTime = null;
|
||||
return true;
|
||||
}
|
||||
console.log('🔍 Health check failed - data.data is not true');
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.log('🔍 Connection error details:', error.message);
|
||||
if (error.response) {
|
||||
console.log('🔍 Response status:', error.response.status);
|
||||
console.log('🔍 Response data:', error.response.data);
|
||||
}
|
||||
this.lastError = `Listmonk connection failed: ${error.message}`;
|
||||
this.lastErrorTime = new Date();
|
||||
logger.error(this.lastError);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize all lists on startup
|
||||
async initializeLists() {
|
||||
if (!this.syncEnabled) {
|
||||
logger.info('Listmonk sync is disabled');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check connection first
|
||||
const connected = await this.checkConnection();
|
||||
if (!connected) {
|
||||
throw new Error(`Cannot connect to Listmonk: ${this.lastError || 'Unknown connection error'}`);
|
||||
}
|
||||
|
||||
// Create main campaign lists
|
||||
this.lists.allCampaigns = await this.ensureList({
|
||||
name: 'Influence - All Campaigns',
|
||||
type: 'private',
|
||||
optin: 'single',
|
||||
tags: ['influence', 'campaigns', 'automated'],
|
||||
description: 'All campaign participants from the influence tool'
|
||||
});
|
||||
|
||||
this.lists.activeCampaigns = await this.ensureList({
|
||||
name: 'Influence - Active Campaigns',
|
||||
type: 'private',
|
||||
optin: 'single',
|
||||
tags: ['influence', 'active', 'automated'],
|
||||
description: 'Participants in active campaigns only'
|
||||
});
|
||||
|
||||
this.lists.customRecipients = await this.ensureList({
|
||||
name: 'Influence - Custom Recipients',
|
||||
type: 'private',
|
||||
optin: 'single',
|
||||
tags: ['influence', 'custom-recipients', 'automated'],
|
||||
description: 'Custom recipients added to campaigns'
|
||||
});
|
||||
|
||||
this.lists.campaignParticipants = await this.ensureList({
|
||||
name: 'Influence - Campaign Participants',
|
||||
type: 'private',
|
||||
optin: 'single',
|
||||
tags: ['influence', 'participants', 'automated'],
|
||||
description: 'Users who have participated in sending campaign emails'
|
||||
});
|
||||
|
||||
this.lists.emailLogs = await this.ensureList({
|
||||
name: 'Influence - Email Logs',
|
||||
type: 'private',
|
||||
optin: 'single',
|
||||
tags: ['influence', 'email-logs', 'automated'],
|
||||
description: 'All email activity from the public influence service'
|
||||
});
|
||||
|
||||
logger.info('✅ Listmonk main lists initialized successfully');
|
||||
|
||||
// Initialize campaign-specific lists for all campaigns
|
||||
try {
|
||||
const nocodbService = require('./nocodb');
|
||||
const campaigns = await nocodbService.getAllCampaigns();
|
||||
|
||||
if (campaigns && campaigns.length > 0) {
|
||||
logger.info(`🔄 Initializing lists for ${campaigns.length} campaigns...`);
|
||||
|
||||
for (const campaign of campaigns) {
|
||||
const slug = campaign['Campaign Slug'];
|
||||
const title = campaign['Campaign Title'];
|
||||
const status = campaign['Status'];
|
||||
|
||||
if (slug && title) {
|
||||
try {
|
||||
const campaignList = await this.ensureCampaignList(slug, title);
|
||||
if (campaignList) {
|
||||
logger.info(`📋 Initialized list for campaign: ${title} (${status})`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`Failed to initialize list for campaign ${title}:`, error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`✅ Campaign lists initialized: ${Object.keys(this.lists.campaignLists).length} lists`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Failed to initialize campaign-specific lists:', error.message);
|
||||
// Don't fail the entire initialization if campaign lists fail
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
this.lastError = `Failed to initialize Listmonk lists: ${error.message}`;
|
||||
this.lastErrorTime = new Date();
|
||||
logger.error(this.lastError);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure a list exists, create if not
|
||||
async ensureList(listConfig) {
|
||||
try {
|
||||
const client = this.getClient();
|
||||
|
||||
// First, try to find existing list by name
|
||||
const { data: listsResponse } = await client.get('/lists');
|
||||
const existingList = listsResponse.data.results.find(list => list.name === listConfig.name);
|
||||
|
||||
if (existingList) {
|
||||
logger.info(`📋 Found existing list: ${listConfig.name}`);
|
||||
return existingList;
|
||||
}
|
||||
|
||||
// Create new list
|
||||
const { data: createResponse } = await client.post('/lists', listConfig);
|
||||
logger.info(`📋 Created new list: ${listConfig.name}`);
|
||||
return createResponse.data;
|
||||
|
||||
} catch (error) {
|
||||
logger.error(`Failed to ensure list ${listConfig.name}:`, error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure a list exists for a specific campaign
|
||||
async ensureCampaignList(campaignSlug, campaignTitle) {
|
||||
if (!this.syncEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if we already have this campaign list cached
|
||||
if (this.lists.campaignLists[campaignSlug]) {
|
||||
return this.lists.campaignLists[campaignSlug];
|
||||
}
|
||||
|
||||
try {
|
||||
const listConfig = {
|
||||
name: `Campaign: ${campaignTitle}`,
|
||||
type: 'private',
|
||||
optin: 'single',
|
||||
tags: ['influence', 'campaign', campaignSlug, 'automated'],
|
||||
description: `Participants who sent emails for the "${campaignTitle}" campaign`
|
||||
};
|
||||
|
||||
const list = await this.ensureList(listConfig);
|
||||
this.lists.campaignLists[campaignSlug] = list;
|
||||
logger.info(`✅ Campaign list created/found for: ${campaignTitle}`);
|
||||
|
||||
return list;
|
||||
} catch (error) {
|
||||
logger.error(`Failed to ensure campaign list for ${campaignSlug}:`, error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Sync a campaign participant to Listmonk
|
||||
async syncCampaignParticipant(emailData, campaignData) {
|
||||
// Map NocoDB field names (column titles) to properties
|
||||
// Try User fields first (new Campaign Emails table), fall back to Sender fields (old table)
|
||||
const userEmail = emailData['User Email'] || emailData['Sender Email'] || emailData.sender_email;
|
||||
const userName = emailData['User Name'] || emailData['Sender Name'] || emailData.sender_name;
|
||||
const userPostalCode = emailData['User Postal Code'] || emailData['Postal Code'] || emailData.postal_code;
|
||||
const createdAt = emailData['CreatedAt'] || emailData.created_at;
|
||||
const sentTo = emailData['Sent To'] || emailData.sent_to;
|
||||
const recipientEmail = emailData['Recipient Email'];
|
||||
const recipientName = emailData['Recipient Name'];
|
||||
|
||||
if (!this.syncEnabled || !userEmail) {
|
||||
return { success: false, error: 'Sync disabled or no email provided' };
|
||||
}
|
||||
|
||||
// Validate and clean the email address
|
||||
const emailValidation = this.validateAndCleanEmail(userEmail);
|
||||
if (!emailValidation.valid) {
|
||||
logger.warn(`Skipping invalid email: ${userEmail} - ${emailValidation.error}`);
|
||||
return { success: false, error: emailValidation.error };
|
||||
}
|
||||
|
||||
try {
|
||||
const subscriberLists = [this.lists.allCampaigns.id, this.lists.campaignParticipants.id];
|
||||
|
||||
// Add to active campaigns list if campaign is active
|
||||
const campaignStatus = campaignData?.Status;
|
||||
if (campaignStatus === 'active') {
|
||||
subscriberLists.push(this.lists.activeCampaigns.id);
|
||||
}
|
||||
|
||||
// Add to campaign-specific list
|
||||
const campaignSlug = campaignData?.['Campaign Slug'];
|
||||
const campaignTitle = campaignData?.['Campaign Title'];
|
||||
|
||||
if (campaignSlug && campaignTitle) {
|
||||
const campaignList = await this.ensureCampaignList(campaignSlug, campaignTitle);
|
||||
if (campaignList) {
|
||||
subscriberLists.push(campaignList.id);
|
||||
logger.info(`📧 Added ${emailValidation.cleaned} to campaign list: ${campaignTitle}`);
|
||||
}
|
||||
}
|
||||
|
||||
const subscriberData = {
|
||||
email: emailValidation.cleaned,
|
||||
name: userName || emailValidation.cleaned,
|
||||
status: 'enabled',
|
||||
lists: subscriberLists,
|
||||
attribs: {
|
||||
last_campaign: campaignTitle || 'Unknown',
|
||||
campaign_slug: campaignSlug || null,
|
||||
last_sent: createdAt ? new Date(createdAt).toISOString() : new Date().toISOString(),
|
||||
postal_code: userPostalCode || null,
|
||||
sent_to_representatives: sentTo || null,
|
||||
last_recipient_email: recipientEmail || null,
|
||||
last_recipient_name: recipientName || null
|
||||
}
|
||||
};
|
||||
|
||||
const result = await this.upsertSubscriber(subscriberData);
|
||||
return { success: true, subscriberId: result.id };
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync campaign participant:', error.message);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// Sync a custom recipient to Listmonk
|
||||
async syncCustomRecipient(recipientData, campaignData) {
|
||||
// Map NocoDB field names (column titles) to properties
|
||||
const email = recipientData['Recipient Email'];
|
||||
const name = recipientData['Recipient Name'];
|
||||
const title = recipientData['Recipient Title'];
|
||||
const organization = recipientData['Recipient Organization'];
|
||||
const phone = recipientData['Recipient Phone'];
|
||||
const createdAt = recipientData['CreatedAt'];
|
||||
const campaignId = recipientData['Campaign ID'];
|
||||
|
||||
if (!this.syncEnabled || !email) {
|
||||
return { success: false, error: 'Sync disabled or no email provided' };
|
||||
}
|
||||
|
||||
// Validate and clean the email address
|
||||
const emailValidation = this.validateAndCleanEmail(email);
|
||||
if (!emailValidation.valid) {
|
||||
logger.warn(`Skipping invalid recipient email: ${email} - ${emailValidation.error}`);
|
||||
return { success: false, error: emailValidation.error };
|
||||
}
|
||||
|
||||
try {
|
||||
const subscriberLists = [this.lists.customRecipients.id];
|
||||
|
||||
// Add to campaign-specific list
|
||||
const campaignSlug = campaignData?.['Campaign Slug'];
|
||||
const campaignTitle = campaignData?.['Campaign Title'];
|
||||
|
||||
if (campaignSlug && campaignTitle) {
|
||||
const campaignList = await this.ensureCampaignList(campaignSlug, campaignTitle);
|
||||
if (campaignList) {
|
||||
subscriberLists.push(campaignList.id);
|
||||
logger.info(`📧 Added recipient ${emailValidation.cleaned} to campaign list: ${campaignTitle}`);
|
||||
}
|
||||
}
|
||||
|
||||
const subscriberData = {
|
||||
email: emailValidation.cleaned,
|
||||
name: name || emailValidation.cleaned,
|
||||
status: 'enabled',
|
||||
lists: subscriberLists,
|
||||
attribs: {
|
||||
campaign: campaignTitle || 'Unknown',
|
||||
campaign_slug: campaignSlug || null,
|
||||
title: title || null,
|
||||
organization: organization || null,
|
||||
phone: phone || null,
|
||||
added_date: createdAt ? new Date(createdAt).toISOString() : null,
|
||||
recipient_type: 'custom'
|
||||
}
|
||||
};
|
||||
|
||||
const result = await this.upsertSubscriber(subscriberData);
|
||||
return { success: true, subscriberId: result.id };
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync custom recipient:', error.message);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// Sync an email log entry to Listmonk (generic public emails)
|
||||
async syncEmailLog(emailData) {
|
||||
// Map NocoDB field names for the Email Logs table
|
||||
const senderEmail = emailData['Sender Email'];
|
||||
const senderName = emailData['Sender Name'];
|
||||
const recipientEmail = emailData['Recipient Email'];
|
||||
const postalCode = emailData['Postal Code'];
|
||||
const createdAt = emailData['CreatedAt'];
|
||||
const sentAt = emailData['Sent At'];
|
||||
|
||||
if (!this.syncEnabled || !senderEmail) {
|
||||
return { success: false, error: 'Sync disabled or no email provided' };
|
||||
}
|
||||
|
||||
// Validate and clean the email address
|
||||
const emailValidation = this.validateAndCleanEmail(senderEmail);
|
||||
if (!emailValidation.valid) {
|
||||
logger.warn(`Skipping invalid email log: ${senderEmail} - ${emailValidation.error}`);
|
||||
return { success: false, error: emailValidation.error };
|
||||
}
|
||||
|
||||
try {
|
||||
const subscriberData = {
|
||||
email: emailValidation.cleaned,
|
||||
name: senderName || emailValidation.cleaned,
|
||||
status: 'enabled',
|
||||
lists: [this.lists.emailLogs.id],
|
||||
attribs: {
|
||||
last_sent: sentAt ? new Date(sentAt).toISOString() : (createdAt ? new Date(createdAt).toISOString() : new Date().toISOString()),
|
||||
postal_code: postalCode || null,
|
||||
last_recipient_email: recipientEmail || null,
|
||||
source: 'email_logs'
|
||||
}
|
||||
};
|
||||
|
||||
const result = await this.upsertSubscriber(subscriberData);
|
||||
return { success: true, subscriberId: result.id };
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync email log:', error.message);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// Upsert subscriber (create or update)
|
||||
async upsertSubscriber(subscriberData) {
|
||||
try {
|
||||
const client = this.getClient();
|
||||
|
||||
// Try to find existing subscriber by email
|
||||
const { data: searchResponse } = await client.get('/subscribers', {
|
||||
params: { query: `subscribers.email = '${subscriberData.email}'` }
|
||||
});
|
||||
|
||||
if (searchResponse.data.results && searchResponse.data.results.length > 0) {
|
||||
// Update existing subscriber
|
||||
const existingSubscriber = searchResponse.data.results[0];
|
||||
const subscriberId = existingSubscriber.id;
|
||||
|
||||
// Merge lists (don't remove existing ones)
|
||||
const existingLists = existingSubscriber.lists.map(l => l.id);
|
||||
const newLists = [...new Set([...existingLists, ...subscriberData.lists])];
|
||||
|
||||
// Merge attributes
|
||||
const mergedAttribs = {
|
||||
...existingSubscriber.attribs,
|
||||
...subscriberData.attribs
|
||||
};
|
||||
|
||||
const updateData = {
|
||||
...subscriberData,
|
||||
lists: newLists,
|
||||
attribs: mergedAttribs
|
||||
};
|
||||
|
||||
const { data: updateResponse } = await client.put(`/subscribers/${subscriberId}`, updateData);
|
||||
logger.info(`Updated subscriber: ${subscriberData.email}`);
|
||||
return updateResponse.data;
|
||||
|
||||
} else {
|
||||
// Create new subscriber
|
||||
const { data: createResponse } = await client.post('/subscribers', subscriberData);
|
||||
logger.info(`Created new subscriber: ${subscriberData.email}`);
|
||||
return createResponse.data;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
logger.error(`Failed to upsert subscriber ${subscriberData.email}:`, error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Bulk sync campaign participants
|
||||
async bulkSyncCampaignParticipants(emails, campaigns) {
|
||||
if (!this.syncEnabled) {
|
||||
return { total: 0, success: 0, failed: 0, errors: [] };
|
||||
}
|
||||
|
||||
const results = {
|
||||
total: emails.length,
|
||||
success: 0,
|
||||
failed: 0,
|
||||
errors: []
|
||||
};
|
||||
|
||||
// Create a map of campaign IDs to campaign data for quick lookup
|
||||
const campaignMap = {};
|
||||
if (campaigns && Array.isArray(campaigns)) {
|
||||
console.log(`🔍 Building campaign map from ${campaigns.length} campaigns`);
|
||||
campaigns.forEach((campaign, index) => {
|
||||
// Show keys for first campaign to debug
|
||||
if (index === 0) {
|
||||
console.log('🔍 First campaign keys:', Object.keys(campaign));
|
||||
}
|
||||
|
||||
// NocoDB returns 'ID' (all caps) as the system field for record ID
|
||||
// This is what the 'Campaign ID' link field in emails table references
|
||||
const id = campaign.ID || campaign.Id || campaign.id;
|
||||
if (id) {
|
||||
campaignMap[id] = campaign;
|
||||
// Also map by slug for fallback lookup
|
||||
const slug = campaign['Campaign Slug'];
|
||||
if (slug) {
|
||||
campaignMap[slug] = campaign;
|
||||
}
|
||||
const title = campaign['Campaign Title'];
|
||||
console.log(`🔍 Mapped campaign ID ${id} and slug ${slug}: ${title}`);
|
||||
} else {
|
||||
console.log('⚠️ Campaign has no ID field! Keys:', Object.keys(campaign));
|
||||
}
|
||||
});
|
||||
console.log(`🔍 Campaign map has ${Object.keys(campaignMap).length} entries`);
|
||||
} else {
|
||||
console.log('⚠️ No campaigns provided for mapping!');
|
||||
}
|
||||
|
||||
for (const email of emails) {
|
||||
try {
|
||||
// Try to find campaign data by Campaign ID (link field) or Campaign Slug (text field)
|
||||
const campaignId = email['Campaign ID'];
|
||||
const campaignSlug = email['Campaign Slug'];
|
||||
const campaignData = campaignId ? campaignMap[campaignId] : (campaignSlug ? campaignMap[campaignSlug] : null);
|
||||
|
||||
// Debug first email
|
||||
if (emails.indexOf(email) === 0) {
|
||||
console.log('🔍 First email keys:', Object.keys(email));
|
||||
console.log('🔍 First email Campaign ID field:', email['Campaign ID']);
|
||||
console.log('🔍 First email Campaign Slug field:', email['Campaign Slug']);
|
||||
}
|
||||
|
||||
if (!campaignData && (campaignId || campaignSlug)) {
|
||||
console.log(`⚠️ Campaign not found - ID: ${campaignId}, Slug: ${campaignSlug}. Available IDs:`, Object.keys(campaignMap).slice(0, 10));
|
||||
}
|
||||
|
||||
const result = await this.syncCampaignParticipant(email, campaignData);
|
||||
|
||||
if (result.success) {
|
||||
results.success++;
|
||||
} else {
|
||||
results.failed++;
|
||||
const emailAddr = email['Sender Email'] || email.sender_email || 'unknown';
|
||||
results.errors.push({
|
||||
email: emailAddr,
|
||||
error: result.error
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
results.failed++;
|
||||
const emailAddr = email['Sender Email'] || email.sender_email || 'unknown';
|
||||
results.errors.push({
|
||||
email: emailAddr,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Bulk sync completed: ${results.success} succeeded, ${results.failed} failed`);
|
||||
return results;
|
||||
}
|
||||
|
||||
// Bulk sync custom recipients
|
||||
async bulkSyncCustomRecipients(recipients, campaigns) {
|
||||
if (!this.syncEnabled) {
|
||||
return { total: 0, success: 0, failed: 0, errors: [] };
|
||||
}
|
||||
|
||||
const results = {
|
||||
total: recipients.length,
|
||||
success: 0,
|
||||
failed: 0,
|
||||
errors: []
|
||||
};
|
||||
|
||||
// Create a map of campaign IDs to campaign data for quick lookup
|
||||
const campaignMap = {};
|
||||
if (campaigns && Array.isArray(campaigns)) {
|
||||
campaigns.forEach(campaign => {
|
||||
// NocoDB returns 'ID' (all caps) as the system field for record ID
|
||||
const id = campaign.ID || campaign.Id || campaign.id;
|
||||
if (id) {
|
||||
campaignMap[id] = campaign;
|
||||
// Also map by slug for fallback lookup
|
||||
const slug = campaign['Campaign Slug'];
|
||||
if (slug) {
|
||||
campaignMap[slug] = campaign;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
for (const recipient of recipients) {
|
||||
try {
|
||||
// Try to find campaign data by Campaign ID or Campaign Slug
|
||||
const campaignId = recipient['Campaign ID'];
|
||||
const campaignSlug = recipient['Campaign Slug'];
|
||||
const campaignData = campaignId ? campaignMap[campaignId] : (campaignSlug ? campaignMap[campaignSlug] : null);
|
||||
const result = await this.syncCustomRecipient(recipient, campaignData);
|
||||
|
||||
if (result.success) {
|
||||
results.success++;
|
||||
} else {
|
||||
results.failed++;
|
||||
const emailAddr = recipient['Recipient Email'] || 'unknown';
|
||||
results.errors.push({
|
||||
email: emailAddr,
|
||||
error: result.error
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
results.failed++;
|
||||
const emailAddr = recipient['Recipient Email'] || 'unknown';
|
||||
results.errors.push({
|
||||
email: emailAddr,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Bulk sync custom recipients completed: ${results.success} succeeded, ${results.failed} failed`);
|
||||
return results;
|
||||
}
|
||||
|
||||
// Bulk sync email logs
|
||||
async bulkSyncEmailLogs(emailLogs) {
|
||||
if (!this.syncEnabled) {
|
||||
return { total: 0, success: 0, failed: 0, errors: [] };
|
||||
}
|
||||
|
||||
const results = {
|
||||
total: emailLogs.length,
|
||||
success: 0,
|
||||
failed: 0,
|
||||
errors: []
|
||||
};
|
||||
|
||||
for (const emailLog of emailLogs) {
|
||||
try {
|
||||
const result = await this.syncEmailLog(emailLog);
|
||||
|
||||
if (result.success) {
|
||||
results.success++;
|
||||
} else {
|
||||
results.failed++;
|
||||
const emailAddr = emailLog['Sender Email'] || 'unknown';
|
||||
results.errors.push({
|
||||
email: emailAddr,
|
||||
error: result.error
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
results.failed++;
|
||||
const emailAddr = emailLog['Sender Email'] || 'unknown';
|
||||
results.errors.push({
|
||||
email: emailAddr,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`Bulk sync email logs completed: ${results.success} succeeded, ${results.failed} failed`);
|
||||
return results;
|
||||
}
|
||||
|
||||
// Get list statistics
|
||||
async getListStats() {
|
||||
if (!this.syncEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const client = this.getClient();
|
||||
const { data: listsResponse } = await client.get('/lists');
|
||||
|
||||
const stats = {};
|
||||
const influenceLists = listsResponse.data.results.filter(list =>
|
||||
list.tags && list.tags.includes('influence')
|
||||
);
|
||||
|
||||
for (const list of influenceLists) {
|
||||
stats[list.name] = {
|
||||
name: list.name,
|
||||
subscriber_count: list.subscriber_count || 0,
|
||||
id: list.id
|
||||
};
|
||||
}
|
||||
|
||||
return stats;
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to get list stats:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Get sync status
|
||||
getSyncStatus() {
|
||||
return {
|
||||
enabled: this.syncEnabled,
|
||||
connected: this.lastError === null,
|
||||
lastError: this.lastError,
|
||||
lastErrorTime: this.lastErrorTime,
|
||||
listsInitialized: Object.values(this.lists).every(list => list !== null)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Create singleton instance
|
||||
const listmonkService = new ListmonkService();
|
||||
|
||||
// Initialize lists on startup if enabled
|
||||
if (listmonkService.syncEnabled) {
|
||||
listmonkService.initializeLists()
|
||||
.then(async () => {
|
||||
logger.info('✅ Listmonk service initialized successfully');
|
||||
|
||||
// Optional initial sync (only if explicitly enabled)
|
||||
if (process.env.LISTMONK_INITIAL_SYNC === 'true') {
|
||||
logger.info('🔄 Performing initial Listmonk sync for influence system...');
|
||||
|
||||
// Use setTimeout to delay initial sync to let app fully start
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const nocodbService = require('./nocodb');
|
||||
|
||||
// Sync existing campaign participants
|
||||
try {
|
||||
// Use campaignEmails table (not emails) to get proper User Email/Name fields
|
||||
const emailsData = await nocodbService.getAll(nocodbService.tableIds.campaignEmails);
|
||||
const emails = emailsData?.list || [];
|
||||
console.log('🔍 Initial sync - fetched campaign emails:', emails?.length || 0);
|
||||
if (emails && emails.length > 0) {
|
||||
console.log('🔍 First email full data:', JSON.stringify(emails[0], null, 2));
|
||||
}
|
||||
|
||||
if (emails && emails.length > 0) {
|
||||
const campaigns = await nocodbService.getAllCampaigns();
|
||||
console.log('🔍 Campaigns fetched:', campaigns?.length || 0);
|
||||
if (campaigns && campaigns.length > 0) {
|
||||
console.log('🔍 First campaign full data:', JSON.stringify(campaigns[0], null, 2));
|
||||
}
|
||||
const emailResults = await listmonkService.bulkSyncCampaignParticipants(emails, campaigns);
|
||||
logger.info(`📧 Initial campaign participants sync: ${emailResults.success} succeeded, ${emailResults.failed} failed`);
|
||||
} else {
|
||||
logger.warn('No campaign participants found for initial sync');
|
||||
}
|
||||
} catch (emailError) {
|
||||
logger.warn('Initial campaign participants sync failed:', {
|
||||
message: emailError.message,
|
||||
stack: emailError.stack
|
||||
});
|
||||
}
|
||||
|
||||
// Sync existing custom recipients
|
||||
try {
|
||||
const recipientsData = await nocodbService.getAll(nocodbService.tableIds.customRecipients);
|
||||
const recipients = recipientsData?.list || [];
|
||||
console.log('🔍 Initial sync - fetched custom recipients:', recipients?.length || 0);
|
||||
|
||||
if (recipients && recipients.length > 0) {
|
||||
const campaigns = await nocodbService.getAllCampaigns();
|
||||
const recipientResults = await listmonkService.bulkSyncCustomRecipients(recipients, campaigns);
|
||||
logger.info(`📋 Initial custom recipients sync: ${recipientResults.success} succeeded, ${recipientResults.failed} failed`);
|
||||
} else {
|
||||
logger.warn('No custom recipients found for initial sync');
|
||||
}
|
||||
} catch (recipientError) {
|
||||
logger.warn('Initial custom recipients sync failed:', {
|
||||
message: recipientError.message,
|
||||
stack: recipientError.stack
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('✅ Initial Listmonk sync completed');
|
||||
} catch (error) {
|
||||
logger.error('Initial Listmonk sync failed:', {
|
||||
message: error.message,
|
||||
stack: error.stack
|
||||
});
|
||||
}
|
||||
}, 5000); // Wait 5 seconds for app to fully start
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
logger.error('Failed to initialize Listmonk service:', error.message);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = listmonkService;
|
||||
1253
influence/app/services/nocodb.js
Normal file
1253
influence/app/services/nocodb.js
Normal file
File diff suppressed because it is too large
Load Diff
152
influence/app/services/qrcode.js
Normal file
152
influence/app/services/qrcode.js
Normal file
@@ -0,0 +1,152 @@
|
||||
const QRCode = require('qrcode');
|
||||
const axios = require('axios');
|
||||
const FormData = require('form-data');
|
||||
|
||||
/**
|
||||
* QR Code Generation Service
|
||||
* Generates QR codes for campaign and response wall URLs
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generate QR code as PNG buffer
|
||||
* @param {string} text - Text/URL to encode
|
||||
* @param {Object} options - QR code options
|
||||
* @returns {Promise<Buffer>} PNG buffer
|
||||
*/
|
||||
async function generateQRCode(text, options = {}) {
|
||||
const defaultOptions = {
|
||||
type: 'png',
|
||||
width: 256,
|
||||
margin: 1,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#FFFFFF'
|
||||
},
|
||||
errorCorrectionLevel: 'M'
|
||||
};
|
||||
|
||||
const qrOptions = { ...defaultOptions, ...options };
|
||||
|
||||
try {
|
||||
const buffer = await QRCode.toBuffer(text, qrOptions);
|
||||
return buffer;
|
||||
} catch (error) {
|
||||
console.error('Failed to generate QR code:', error);
|
||||
throw new Error('Failed to generate QR code');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload QR code to NocoDB storage
|
||||
* @param {Buffer} buffer - PNG buffer
|
||||
* @param {string} filename - Filename for the upload
|
||||
* @param {Object} config - NocoDB configuration
|
||||
* @returns {Promise<Object>} Upload response
|
||||
*/
|
||||
async function uploadQRCodeToNocoDB(buffer, filename, config) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', buffer, {
|
||||
filename: filename,
|
||||
contentType: 'image/png'
|
||||
});
|
||||
|
||||
try {
|
||||
// Use the base URL without /api/v1 for v2 endpoints
|
||||
const baseUrl = config.apiUrl.replace('/api/v1', '');
|
||||
const uploadUrl = `${baseUrl}/api/v2/storage/upload`;
|
||||
|
||||
console.log(`Uploading QR code to: ${uploadUrl}`);
|
||||
|
||||
const response = await axios({
|
||||
url: uploadUrl,
|
||||
method: 'post',
|
||||
data: formData,
|
||||
headers: {
|
||||
...formData.getHeaders(),
|
||||
'xc-token': config.apiToken
|
||||
},
|
||||
params: {
|
||||
path: 'qrcodes'
|
||||
}
|
||||
});
|
||||
|
||||
console.log('QR code upload successful:', response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to upload QR code to NocoDB:', error.response?.data || error.message);
|
||||
throw new Error('Failed to upload QR code');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate and upload QR code
|
||||
* @param {string} url - URL to encode
|
||||
* @param {string} label - Label for the QR code
|
||||
* @param {Object} config - NocoDB configuration
|
||||
* @returns {Promise<Object>} Upload result
|
||||
*/
|
||||
async function generateAndUploadQRCode(url, label, config) {
|
||||
if (!url) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// Generate QR code
|
||||
const buffer = await generateQRCode(url);
|
||||
|
||||
// Create filename
|
||||
const timestamp = Date.now();
|
||||
const safeLabel = label.replace(/[^a-z0-9]/gi, '_').toLowerCase();
|
||||
const filename = `qr_${safeLabel}_${timestamp}.png`;
|
||||
|
||||
// Upload to NocoDB
|
||||
const uploadResult = await uploadQRCodeToNocoDB(buffer, filename, config);
|
||||
|
||||
return uploadResult;
|
||||
} catch (error) {
|
||||
console.error('Failed to generate and upload QR code:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete QR code from NocoDB storage
|
||||
* @param {string} fileUrl - File URL to delete
|
||||
* @param {Object} config - NocoDB configuration
|
||||
* @returns {Promise<boolean>} Success status
|
||||
*/
|
||||
async function deleteQRCodeFromNocoDB(fileUrl, config) {
|
||||
if (!fileUrl) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
// Extract file path from URL
|
||||
const urlParts = fileUrl.split('/');
|
||||
const filePath = urlParts.slice(-2).join('/');
|
||||
|
||||
await axios({
|
||||
url: `${config.apiUrl}/api/v2/storage/upload`,
|
||||
method: 'delete',
|
||||
headers: {
|
||||
'xc-token': config.apiToken
|
||||
},
|
||||
params: {
|
||||
path: filePath
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to delete QR code from NocoDB:', error);
|
||||
// Don't throw error for deletion failures
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateQRCode,
|
||||
uploadQRCodeToNocoDB,
|
||||
generateAndUploadQRCode,
|
||||
deleteQRCodeFromNocoDB
|
||||
};
|
||||
146
influence/app/services/represent-api.js
Normal file
146
influence/app/services/represent-api.js
Normal file
@@ -0,0 +1,146 @@
|
||||
const axios = require('axios');
|
||||
|
||||
class RepresentAPIService {
|
||||
constructor() {
|
||||
this.baseURL = process.env.REPRESENT_API_BASE || 'https://represent.opennorth.ca';
|
||||
this.rateLimit = parseInt(process.env.REPRESENT_API_RATE_LIMIT) || 60;
|
||||
this.lastRequestTime = 0;
|
||||
this.requestCount = 0;
|
||||
this.resetTime = Date.now() + 60000; // Reset every minute
|
||||
}
|
||||
|
||||
async checkRateLimit() {
|
||||
const now = Date.now();
|
||||
|
||||
// Reset counter if a minute has passed
|
||||
if (now > this.resetTime) {
|
||||
this.requestCount = 0;
|
||||
this.resetTime = now + 60000;
|
||||
}
|
||||
|
||||
// Check if we're at the rate limit
|
||||
if (this.requestCount >= this.rateLimit) {
|
||||
const waitTime = this.resetTime - now;
|
||||
throw new Error(`Rate limit exceeded. Please wait ${Math.ceil(waitTime / 1000)} seconds.`);
|
||||
}
|
||||
|
||||
this.requestCount++;
|
||||
this.lastRequestTime = now;
|
||||
}
|
||||
|
||||
async makeRequest(endpoint) {
|
||||
await this.checkRateLimit();
|
||||
|
||||
try {
|
||||
const response = await axios.get(`${this.baseURL}${endpoint}`, {
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'User-Agent': 'Alberta-Influence-Campaign-Tool/1.0'
|
||||
}
|
||||
});
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (error.response) {
|
||||
throw new Error(`API Error: ${error.response.status} - ${error.response.statusText}`);
|
||||
} else if (error.request) {
|
||||
throw new Error('Network error: Unable to reach Represent API');
|
||||
} else {
|
||||
throw new Error(`Request error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async testConnection() {
|
||||
try {
|
||||
const data = await this.makeRequest('/boundary-sets/?limit=1');
|
||||
return {
|
||||
success: true,
|
||||
message: 'Successfully connected to Represent API',
|
||||
sampleData: data
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Failed to connect to Represent API',
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async getRepresentativesByPostalCode(postalCode) {
|
||||
const formattedPostalCode = postalCode.replace(/\s/g, '').toUpperCase();
|
||||
|
||||
// Validate Alberta postal code (should start with T)
|
||||
if (!formattedPostalCode.startsWith('T')) {
|
||||
throw new Error('This tool is designed for Alberta postal codes only (starting with T)');
|
||||
}
|
||||
|
||||
try {
|
||||
const endpoint = `/postcodes/${formattedPostalCode}/`;
|
||||
console.log(`Making Represent API request to: ${this.baseURL}${endpoint}`);
|
||||
const data = await this.makeRequest(endpoint);
|
||||
|
||||
console.log('Represent API Response:', JSON.stringify(data, null, 2));
|
||||
console.log(`Representatives concordance count: ${data.representatives_concordance?.length || 0}`);
|
||||
console.log(`Representatives centroid count: ${data.representatives_centroid?.length || 0}`);
|
||||
|
||||
return {
|
||||
postalCode: formattedPostalCode,
|
||||
city: data.city,
|
||||
province: data.province,
|
||||
centroid: data.centroid,
|
||||
representatives_concordance: data.representatives_concordance || [],
|
||||
representatives_centroid: data.representatives_centroid || [],
|
||||
boundaries_concordance: data.boundaries_concordance || [],
|
||||
boundaries_centroid: data.boundaries_centroid || []
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Represent API error for ${formattedPostalCode}:`, error.message);
|
||||
throw new Error(`Failed to fetch data for postal code ${formattedPostalCode}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getRepresentativeDetails(representativeUrl) {
|
||||
try {
|
||||
// Extract the path from the URL
|
||||
const urlPath = representativeUrl.replace(this.baseURL, '');
|
||||
const data = await this.makeRequest(urlPath);
|
||||
return data;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to fetch representative details: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getBoundaryDetails(boundaryUrl) {
|
||||
try {
|
||||
// Extract the path from the URL
|
||||
const urlPath = boundaryUrl.replace(this.baseURL, '');
|
||||
const data = await this.makeRequest(urlPath);
|
||||
return data;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to fetch boundary details: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async searchRepresentatives(filters = {}) {
|
||||
try {
|
||||
const queryParams = new URLSearchParams();
|
||||
|
||||
// Add filters as query parameters
|
||||
Object.keys(filters).forEach(key => {
|
||||
if (filters[key]) {
|
||||
queryParams.append(key, filters[key]);
|
||||
}
|
||||
});
|
||||
|
||||
const endpoint = `/representatives/?${queryParams.toString()}`;
|
||||
const data = await this.makeRequest(endpoint);
|
||||
return data;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to search representatives: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new RepresentAPIService();
|
||||
Reference in New Issue
Block a user