Pushing the new influence app in its current state
This commit is contained in:
128
influence/app/services/email.js
Normal file
128
influence/app/services/email.js
Normal file
@@ -0,0 +1,128 @@
|
||||
const nodemailer = require('nodemailer');
|
||||
|
||||
class EmailService {
|
||||
constructor() {
|
||||
this.transporter = null;
|
||||
this.initializeTransporter();
|
||||
}
|
||||
|
||||
initializeTransporter() {
|
||||
try {
|
||||
this.transporter = nodemailer.createTransport({
|
||||
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
|
||||
auth: {
|
||||
user: process.env.SMTP_USER,
|
||||
pass: process.env.SMTP_PASS
|
||||
},
|
||||
tls: {
|
||||
rejectUnauthorized: false
|
||||
}
|
||||
});
|
||||
|
||||
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) {
|
||||
try {
|
||||
if (!this.transporter) {
|
||||
throw new Error('Email transporter not initialized');
|
||||
}
|
||||
|
||||
const mailOptions = {
|
||||
from: `"${emailOptions.from.name}" <${emailOptions.from.email}>`,
|
||||
to: emailOptions.to,
|
||||
replyTo: emailOptions.replyTo,
|
||||
subject: emailOptions.subject,
|
||||
text: emailOptions.text,
|
||||
html: emailOptions.html
|
||||
};
|
||||
|
||||
const info = await this.transporter.sendMail(mailOptions);
|
||||
|
||||
console.log('Email sent successfully:', info.messageId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messageId: info.messageId,
|
||||
response: info.response
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Email send error:', error);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new EmailService();
|
||||
459
influence/app/services/nocodb.js
Normal file
459
influence/app/services/nocodb.js
Normal file
@@ -0,0 +1,459 @@
|
||||
const axios = require('axios');
|
||||
|
||||
class NocoDBService {
|
||||
constructor() {
|
||||
// Accept either full API URL or base URL
|
||||
const rawApiUrl = process.env.NOCODB_API_URL || process.env.NOCODB_URL;
|
||||
this.apiToken = process.env.NOCODB_API_TOKEN;
|
||||
this.projectId = process.env.NOCODB_PROJECT_ID;
|
||||
this.timeout = 10000;
|
||||
|
||||
// Normalize base URL and API prefix to avoid double "/api/v1"
|
||||
let baseUrl = rawApiUrl || '';
|
||||
if (baseUrl.endsWith('/')) baseUrl = baseUrl.slice(0, -1);
|
||||
// If env provided includes /api/v1, strip it from base and keep prefix
|
||||
if (/\/api\/v1$/.test(baseUrl)) {
|
||||
baseUrl = baseUrl.replace(/\/api\/v1$/, '');
|
||||
}
|
||||
this.baseUrl = baseUrl || '';
|
||||
this.apiPrefix = '/api/v1';
|
||||
|
||||
// Table mapping from environment variables
|
||||
this.tableIds = {
|
||||
representatives: process.env.NOCODB_TABLE_REPRESENTATIVES,
|
||||
emails: process.env.NOCODB_TABLE_EMAILS,
|
||||
postalCodes: process.env.NOCODB_TABLE_POSTAL_CODES,
|
||||
campaigns: process.env.NOCODB_TABLE_CAMPAIGNS,
|
||||
campaignEmails: process.env.NOCODB_TABLE_CAMPAIGN_EMAILS,
|
||||
users: process.env.NOCODB_TABLE_USERS
|
||||
};
|
||||
|
||||
// Validate that all table IDs are set
|
||||
const missingTables = Object.entries(this.tableIds)
|
||||
.filter(([key, value]) => !value)
|
||||
.map(([key]) => key);
|
||||
|
||||
if (missingTables.length > 0) {
|
||||
console.error('Missing NocoDB table IDs in environment variables:', missingTables);
|
||||
console.error('Please run the build-nocodb.sh script to set up the database tables.');
|
||||
}
|
||||
|
||||
// Create axios instance with normalized base URL
|
||||
this.client = axios.create({
|
||||
baseURL: this.baseUrl,
|
||||
timeout: this.timeout,
|
||||
headers: {
|
||||
'xc-token': this.apiToken,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
// Add response interceptor for error handling
|
||||
this.client.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
console.error('NocoDB API Error:', {
|
||||
message: error.message,
|
||||
url: error.config?.url,
|
||||
method: error.config?.method,
|
||||
status: error.response?.status,
|
||||
data: error.response?.data
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Build table URL using table ID
|
||||
getTableUrl(tableId) {
|
||||
// Always prefix with single "/api/v1"
|
||||
return `${this.apiPrefix}/db/data/v1/${this.projectId}/${tableId}`;
|
||||
}
|
||||
|
||||
// Get all records from a table
|
||||
async getAll(tableId, params = {}) {
|
||||
const url = this.getTableUrl(tableId);
|
||||
const response = await this.client.get(url, { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Create record
|
||||
async create(tableId, data) {
|
||||
try {
|
||||
// Clean data to prevent ID conflicts
|
||||
const cleanData = { ...data };
|
||||
delete cleanData.ID;
|
||||
delete cleanData.id;
|
||||
delete cleanData.Id;
|
||||
|
||||
// Remove undefined values
|
||||
Object.keys(cleanData).forEach(key => {
|
||||
if (cleanData[key] === undefined) {
|
||||
delete cleanData[key];
|
||||
}
|
||||
});
|
||||
|
||||
const url = this.getTableUrl(tableId);
|
||||
const response = await this.client.post(url, cleanData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error creating record:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
async storeRepresentatives(postalCode, representatives) {
|
||||
try {
|
||||
const stored = [];
|
||||
|
||||
for (const rep of representatives) {
|
||||
const record = {
|
||||
postal_code: postalCode,
|
||||
name: rep.name || '',
|
||||
email: rep.email || '',
|
||||
district_name: rep.district_name || '',
|
||||
elected_office: rep.elected_office || '',
|
||||
party_name: rep.party_name || '',
|
||||
representative_set_name: rep.representative_set_name || '',
|
||||
url: rep.url || '',
|
||||
photo_url: rep.photo_url || '',
|
||||
cached_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
const result = await this.create(this.tableIds.representatives, record);
|
||||
stored.push(result);
|
||||
}
|
||||
|
||||
return { success: true, count: stored.length };
|
||||
} catch (error) {
|
||||
// If we get a server error, don't throw - just log and return failure
|
||||
if (error.response && error.response.status >= 500) {
|
||||
console.log('NocoDB server unavailable, cannot cache representatives');
|
||||
return { success: false, error: 'Server unavailable' };
|
||||
}
|
||||
|
||||
console.log('Error storing representatives:', error.response?.data?.msg || error.message);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async getRepresentativesByPostalCode(postalCode) {
|
||||
try {
|
||||
// Try to query with the most likely column name
|
||||
const response = await this.getAll(this.tableIds.representatives, {
|
||||
where: `(postal_code,eq,${postalCode})`
|
||||
});
|
||||
|
||||
return response.list || [];
|
||||
} catch (error) {
|
||||
// If we get a 502 or other server error, just return empty array
|
||||
if (error.response && (error.response.status === 502 || error.response.status >= 500)) {
|
||||
console.log('NocoDB server unavailable (502/5xx error), returning empty cache result');
|
||||
return [];
|
||||
}
|
||||
|
||||
// For other errors like column not found, also return empty array
|
||||
console.log('NocoDB cache error, returning empty array:', error.response?.data?.msg || error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async clearRepresentativesByPostalCode(postalCode) {
|
||||
try {
|
||||
// Get existing records
|
||||
const existing = await this.getRepresentativesByPostalCode(postalCode);
|
||||
|
||||
// Delete each record using client
|
||||
for (const record of existing) {
|
||||
const url = `${this.getTableUrl(this.tableIds.representatives)}/${record.Id}`;
|
||||
await this.client.delete(url);
|
||||
}
|
||||
|
||||
return { success: true, deleted: existing.length };
|
||||
} catch (error) {
|
||||
console.error('Error clearing representatives:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async logEmailSend(emailData) {
|
||||
try {
|
||||
const record = {
|
||||
recipient_email: emailData.recipientEmail,
|
||||
sender_name: emailData.senderName,
|
||||
sender_email: emailData.senderEmail,
|
||||
subject: emailData.subject,
|
||||
postal_code: emailData.postalCode,
|
||||
status: emailData.status,
|
||||
sent_at: emailData.timestamp
|
||||
};
|
||||
|
||||
await this.create(this.tableIds.emails, record);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error logging email:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getEmailLogs(filters = {}) {
|
||||
try {
|
||||
let whereClause = '';
|
||||
const conditions = [];
|
||||
|
||||
if (filters.postalCode) {
|
||||
conditions.push(`(postal_code,eq,${filters.postalCode})`);
|
||||
}
|
||||
if (filters.senderEmail) {
|
||||
conditions.push(`(sender_email,eq,${filters.senderEmail})`);
|
||||
}
|
||||
if (filters.status) {
|
||||
conditions.push(`(status,eq,${filters.status})`);
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
whereClause = `?where=${conditions.join('~and')}`;
|
||||
}
|
||||
|
||||
const params = {};
|
||||
if (conditions.length > 0) {
|
||||
params.where = conditions.join('~and');
|
||||
}
|
||||
params.sort = '-CreatedAt';
|
||||
|
||||
const response = await this.getAll(this.tableIds.emails, params);
|
||||
return response.list || [];
|
||||
} catch (error) {
|
||||
console.error('Error getting email logs:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async storePostalCodeInfo(postalCodeData) {
|
||||
try {
|
||||
const response = await this.create(this.tableIds.postalCodes, postalCodeData);
|
||||
return response;
|
||||
} catch (error) {
|
||||
// Don't throw error for postal code caching failures
|
||||
console.log('Postal code info storage failed:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Campaign management methods
|
||||
async getAllCampaigns() {
|
||||
try {
|
||||
const response = await this.getAll(this.tableIds.campaigns, {
|
||||
sort: '-CreatedAt'
|
||||
});
|
||||
return response.list || [];
|
||||
} catch (error) {
|
||||
console.error('Get all campaigns failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getCampaignById(id) {
|
||||
try {
|
||||
// Use direct record endpoint to avoid casing issues on Id column
|
||||
const url = `${this.getTableUrl(this.tableIds.campaigns)}/${id}`;
|
||||
const response = await this.client.get(url);
|
||||
return response.data || null;
|
||||
} catch (error) {
|
||||
console.error('Get campaign by ID failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getCampaignBySlug(slug) {
|
||||
try {
|
||||
const response = await this.getAll(this.tableIds.campaigns, {
|
||||
where: `(Campaign Slug,eq,${slug})`
|
||||
});
|
||||
return response.list && response.list.length > 0 ? response.list[0] : null;
|
||||
} catch (error) {
|
||||
console.error('Get campaign by slug failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async createCampaign(campaignData) {
|
||||
try {
|
||||
// Map field names to NocoDB column titles
|
||||
const mappedData = {
|
||||
'Campaign Slug': campaignData.slug,
|
||||
'Campaign Title': campaignData.title,
|
||||
'Description': campaignData.description,
|
||||
'Email Subject': campaignData.email_subject,
|
||||
'Email Body': campaignData.email_body,
|
||||
'Call to Action': campaignData.call_to_action,
|
||||
'Status': campaignData.status,
|
||||
'Allow SMTP Email': campaignData.allow_smtp_email,
|
||||
'Allow Mailto Link': campaignData.allow_mailto_link,
|
||||
'Collect User Info': campaignData.collect_user_info,
|
||||
'Show Email Count': campaignData.show_email_count,
|
||||
'Target Government Levels': campaignData.target_government_levels
|
||||
};
|
||||
|
||||
const response = await this.create(this.tableIds.campaigns, mappedData);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Create campaign failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateCampaign(id, updates) {
|
||||
try {
|
||||
// NocoDB update using direct API call
|
||||
const url = `${this.getTableUrl(this.tableIds.campaigns)}/${id}`;
|
||||
const response = await this.client.patch(url, updates);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Update campaign failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteCampaign(id) {
|
||||
try {
|
||||
const url = `${this.getTableUrl(this.tableIds.campaigns)}/${id}`;
|
||||
const response = await this.client.delete(url);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Delete campaign failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Campaign email tracking methods
|
||||
async logCampaignEmail(emailData) {
|
||||
try {
|
||||
const response = await this.create(this.tableIds.campaignEmails, emailData);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Log campaign email failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getCampaignEmailCount(campaignId) {
|
||||
try {
|
||||
const response = await this.getAll(this.tableIds.campaignEmails, {
|
||||
where: `(campaign_id,eq,${campaignId})`,
|
||||
limit: 1000 // Get enough to count
|
||||
});
|
||||
return response.pageInfo ? response.pageInfo.totalRows : (response.list ? response.list.length : 0);
|
||||
} catch (error) {
|
||||
console.error('Get campaign email count failed:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async getCampaignAnalytics(campaignId) {
|
||||
try {
|
||||
const response = await this.getAll(this.tableIds.campaignEmails, {
|
||||
where: `(campaign_id,eq,${campaignId})`,
|
||||
limit: 1000
|
||||
});
|
||||
|
||||
const emails = response.list || [];
|
||||
|
||||
const analytics = {
|
||||
totalEmails: emails.length,
|
||||
smtpEmails: emails.filter(e => e.email_method === 'smtp').length,
|
||||
mailtoClicks: emails.filter(e => e.email_method === 'mailto').length,
|
||||
successfulEmails: emails.filter(e => e.status === 'sent' || e.status === 'clicked').length,
|
||||
failedEmails: emails.filter(e => e.status === 'failed').length,
|
||||
byLevel: {},
|
||||
byDate: {},
|
||||
recentEmails: emails.slice(0, 10).map(email => ({
|
||||
timestamp: email.timestamp,
|
||||
user_name: email.user_name,
|
||||
recipient_name: email.recipient_name,
|
||||
recipient_level: email.recipient_level,
|
||||
email_method: email.email_method,
|
||||
status: email.status
|
||||
}))
|
||||
};
|
||||
|
||||
// Group by government level
|
||||
emails.forEach(email => {
|
||||
const level = email.recipient_level || 'Other';
|
||||
analytics.byLevel[level] = (analytics.byLevel[level] || 0) + 1;
|
||||
});
|
||||
|
||||
// Group by date
|
||||
emails.forEach(email => {
|
||||
if (email.timestamp) {
|
||||
const date = email.timestamp.split('T')[0]; // Get date part
|
||||
analytics.byDate[date] = (analytics.byDate[date] || 0) + 1;
|
||||
}
|
||||
});
|
||||
|
||||
return analytics;
|
||||
} catch (error) {
|
||||
console.error('Get campaign analytics failed:', error);
|
||||
return {
|
||||
totalEmails: 0,
|
||||
smtpEmails: 0,
|
||||
mailtoClicks: 0,
|
||||
successfulEmails: 0,
|
||||
failedEmails: 0,
|
||||
byLevel: {},
|
||||
byDate: {},
|
||||
recentEmails: []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// User management methods
|
||||
async getUserByEmail(email) {
|
||||
if (!this.tableIds.users) {
|
||||
throw new Error('Users table not configured');
|
||||
}
|
||||
|
||||
const response = await this.getAll(this.tableIds.users, {
|
||||
where: `(Email,eq,${email})`,
|
||||
limit: 1
|
||||
});
|
||||
|
||||
return response.list?.[0] || null;
|
||||
}
|
||||
|
||||
async createUser(userData) {
|
||||
if (!this.tableIds.users) {
|
||||
throw new Error('Users table not configured');
|
||||
}
|
||||
|
||||
return await this.create(this.tableIds.users, userData);
|
||||
}
|
||||
|
||||
async updateUser(userId, userData) {
|
||||
if (!this.tableIds.users) {
|
||||
throw new Error('Users table not configured');
|
||||
}
|
||||
|
||||
return await this.update(this.tableIds.users, userId, userData);
|
||||
}
|
||||
|
||||
async deleteUser(userId) {
|
||||
if (!this.tableIds.users) {
|
||||
throw new Error('Users table not configured');
|
||||
}
|
||||
|
||||
return await this.delete(this.tableIds.users, userId);
|
||||
}
|
||||
|
||||
async getAllUsers(params = {}) {
|
||||
if (!this.tableIds.users) {
|
||||
throw new Error('Users table not configured');
|
||||
}
|
||||
|
||||
return await this.getAll(this.tableIds.users, params);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new NocoDBService();
|
||||
375
influence/app/services/nocodb.js.backup
Normal file
375
influence/app/services/nocodb.js.backup
Normal file
@@ -0,0 +1,375 @@
|
||||
const axios = require('axios');
|
||||
|
||||
class NocoDBService {
|
||||
constructor() {
|
||||
this.apiUrl = process.env.NOCODB_API_URL;
|
||||
this.apiToken = process.env.NOCODB_API_TOKEN;
|
||||
this.projectId = process.env.NOCODB_PROJECT_ID;
|
||||
this.timeout = 10000;
|
||||
|
||||
// Table mapping with actual table IDs from NocoDB
|
||||
this.tableIds = {
|
||||
representatives: 'm3slxjt2t9fspvn',
|
||||
emails: 'mclckn23dlsiuvj',
|
||||
postalCodes: 'mfsefv20htd6jy1',
|
||||
campaigns: 'mrbky41y7nahz98',
|
||||
campaignEmails: 'mlij85ls403d7c2'
|
||||
};
|
||||
|
||||
// Create axios instance with defaults like the map service
|
||||
this.client = axios.create({
|
||||
baseURL: this.apiUrl,
|
||||
timeout: this.timeout,
|
||||
headers: {
|
||||
'xc-token': this.apiToken,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
// Add response interceptor for error handling
|
||||
this.client.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
console.error('NocoDB API Error:', {
|
||||
message: error.message,
|
||||
url: error.config?.url,
|
||||
method: error.config?.method,
|
||||
status: error.response?.status,
|
||||
data: error.response?.data
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Build table URL using table ID
|
||||
getTableUrl(tableId) {
|
||||
return `/db/data/v1/${this.projectId}/${tableId}`;
|
||||
}
|
||||
|
||||
// Get all records from a table
|
||||
async getAll(tableId, params = {}) {
|
||||
const url = this.getTableUrl(tableId);
|
||||
const response = await this.client.get(url, { params });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Create record
|
||||
async create(tableId, data) {
|
||||
try {
|
||||
// Clean data to prevent ID conflicts
|
||||
const cleanData = { ...data };
|
||||
delete cleanData.ID;
|
||||
delete cleanData.id;
|
||||
delete cleanData.Id;
|
||||
|
||||
// Remove undefined values
|
||||
Object.keys(cleanData).forEach(key => {
|
||||
if (cleanData[key] === undefined) {
|
||||
delete cleanData[key];
|
||||
}
|
||||
});
|
||||
|
||||
const url = this.getTableUrl(tableId);
|
||||
const response = await this.client.post(url, cleanData);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error creating record:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
async storeRepresentatives(postalCode, representatives) {
|
||||
try {
|
||||
const stored = [];
|
||||
|
||||
for (const rep of representatives) {
|
||||
const record = {
|
||||
postal_code: postalCode,
|
||||
name: rep.name || '',
|
||||
email: rep.email || '',
|
||||
district_name: rep.district_name || '',
|
||||
elected_office: rep.elected_office || '',
|
||||
party_name: rep.party_name || '',
|
||||
representative_set_name: rep.representative_set_name || '',
|
||||
url: rep.url || '',
|
||||
photo_url: rep.photo_url || '',
|
||||
cached_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
const result = await this.create(this.tableIds.representatives, record);
|
||||
stored.push(result);
|
||||
}
|
||||
|
||||
return { success: true, count: stored.length };
|
||||
} catch (error) {
|
||||
// If we get a server error, don't throw - just log and return failure
|
||||
if (error.response && error.response.status >= 500) {
|
||||
console.log('NocoDB server unavailable, cannot cache representatives');
|
||||
return { success: false, error: 'Server unavailable' };
|
||||
}
|
||||
|
||||
console.log('Error storing representatives:', error.response?.data?.msg || error.message);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async getRepresentativesByPostalCode(postalCode) {
|
||||
try {
|
||||
// Try to query with the most likely column name
|
||||
const response = await this.getAll(this.tableIds.representatives, {
|
||||
where: `(postal_code,eq,${postalCode})`
|
||||
});
|
||||
|
||||
return response.list || [];
|
||||
} catch (error) {
|
||||
// If we get a 502 or other server error, just return empty array
|
||||
if (error.response && (error.response.status === 502 || error.response.status >= 500)) {
|
||||
console.log('NocoDB server unavailable (502/5xx error), returning empty cache result');
|
||||
return [];
|
||||
}
|
||||
|
||||
// For other errors like column not found, also return empty array
|
||||
console.log('NocoDB cache error, returning empty array:', error.response?.data?.msg || error.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async clearRepresentativesByPostalCode(postalCode) {
|
||||
try {
|
||||
// Get existing records
|
||||
const existing = await this.getRepresentativesByPostalCode(postalCode);
|
||||
|
||||
// Delete each record using client
|
||||
for (const record of existing) {
|
||||
const url = `${this.getTableUrl(this.tableIds.representatives)}/${record.Id}`;
|
||||
await this.client.delete(url);
|
||||
}
|
||||
|
||||
return { success: true, deleted: existing.length };
|
||||
} catch (error) {
|
||||
console.error('Error clearing representatives:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async logEmailSend(emailData) {
|
||||
try {
|
||||
const record = {
|
||||
recipient_email: emailData.recipientEmail,
|
||||
sender_name: emailData.senderName,
|
||||
sender_email: emailData.senderEmail,
|
||||
subject: emailData.subject,
|
||||
postal_code: emailData.postalCode,
|
||||
status: emailData.status,
|
||||
sent_at: emailData.timestamp,
|
||||
created_at: new Date().toISOString()
|
||||
};
|
||||
|
||||
await this.create(this.tableIds.emails, record);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error logging email:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getEmailLogs(filters = {}) {
|
||||
try {
|
||||
let whereClause = '';
|
||||
const conditions = [];
|
||||
|
||||
if (filters.postalCode) {
|
||||
conditions.push(`(postal_code,eq,${filters.postalCode})`);
|
||||
}
|
||||
if (filters.senderEmail) {
|
||||
conditions.push(`(sender_email,eq,${filters.senderEmail})`);
|
||||
}
|
||||
if (filters.status) {
|
||||
conditions.push(`(status,eq,${filters.status})`);
|
||||
}
|
||||
|
||||
if (conditions.length > 0) {
|
||||
whereClause = `?where=${conditions.join('~and')}`;
|
||||
}
|
||||
|
||||
const params = {};
|
||||
if (conditions.length > 0) {
|
||||
params.where = conditions.join('~and');
|
||||
}
|
||||
params.sort = '-created_at';
|
||||
|
||||
const response = await this.getAll(this.tableIds.emails, params);
|
||||
return response.list || [];
|
||||
} catch (error) {
|
||||
console.error('Error getting email logs:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async storePostalCodeInfo(postalCodeData) {
|
||||
try {
|
||||
const response = await this.create(this.tableIds.postalCodes, postalCodeData);
|
||||
return response;
|
||||
} catch (error) {
|
||||
// Don't throw error for postal code caching failures
|
||||
console.log('Postal code info storage failed:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Campaign management methods
|
||||
async getAllCampaigns() {
|
||||
try {
|
||||
const response = await this.getAll(this.tableIds.campaigns, {
|
||||
sort: '-created_at'
|
||||
});
|
||||
return response.list || [];
|
||||
} catch (error) {
|
||||
console.error('Get all campaigns failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getCampaignById(id) {
|
||||
try {
|
||||
const response = await this.getAll(this.tableIds.campaigns, {
|
||||
where: `(id,eq,${id})`
|
||||
});
|
||||
return response.list && response.list.length > 0 ? response.list[0] : null;
|
||||
} catch (error) {
|
||||
console.error('Get campaign by ID failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getCampaignBySlug(slug) {
|
||||
try {
|
||||
const response = await this.getAll(this.tableIds.campaigns, {
|
||||
where: `(slug,eq,${slug})`
|
||||
});
|
||||
return response.list && response.list.length > 0 ? response.list[0] : null;
|
||||
} catch (error) {
|
||||
console.error('Get campaign by slug failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async createCampaign(campaignData) {
|
||||
try {
|
||||
const response = await this.create(this.tableIds.campaigns, campaignData);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Create campaign failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateCampaign(id, updates) {
|
||||
try {
|
||||
// NocoDB update using direct API call
|
||||
const url = `${this.getTableUrl(this.tableIds.campaigns)}/${id}`;
|
||||
const response = await this.client.patch(url, updates);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Update campaign failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteCampaign(id) {
|
||||
try {
|
||||
const url = `${this.getTableUrl(this.tableIds.campaigns)}/${id}`;
|
||||
const response = await this.client.delete(url);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Delete campaign failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Campaign email tracking methods
|
||||
async logCampaignEmail(emailData) {
|
||||
try {
|
||||
const response = await this.create(this.tableIds.campaignEmails, emailData);
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Log campaign email failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getCampaignEmailCount(campaignId) {
|
||||
try {
|
||||
const response = await this.getAll(this.tableIds.campaignEmails, {
|
||||
where: `(campaign_id,eq,${campaignId})`,
|
||||
limit: 1000 // Get enough to count
|
||||
});
|
||||
return response.pageInfo ? response.pageInfo.totalRows : (response.list ? response.list.length : 0);
|
||||
} catch (error) {
|
||||
console.error('Get campaign email count failed:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
async getCampaignAnalytics(campaignId) {
|
||||
try {
|
||||
const response = await this.getAll(this.tableIds.campaignEmails, {
|
||||
where: `(campaign_id,eq,${campaignId})`,
|
||||
limit: 1000
|
||||
});
|
||||
|
||||
const emails = response.list || [];
|
||||
|
||||
const analytics = {
|
||||
totalEmails: emails.length,
|
||||
smtpEmails: emails.filter(e => e.email_method === 'smtp').length,
|
||||
mailtoClicks: emails.filter(e => e.email_method === 'mailto').length,
|
||||
successfulEmails: emails.filter(e => e.status === 'sent' || e.status === 'clicked').length,
|
||||
failedEmails: emails.filter(e => e.status === 'failed').length,
|
||||
byLevel: {},
|
||||
byDate: {},
|
||||
recentEmails: emails.slice(0, 10).map(email => ({
|
||||
timestamp: email.timestamp,
|
||||
user_name: email.user_name,
|
||||
recipient_name: email.recipient_name,
|
||||
recipient_level: email.recipient_level,
|
||||
email_method: email.email_method,
|
||||
status: email.status
|
||||
}))
|
||||
};
|
||||
|
||||
// Group by government level
|
||||
emails.forEach(email => {
|
||||
const level = email.recipient_level || 'Other';
|
||||
analytics.byLevel[level] = (analytics.byLevel[level] || 0) + 1;
|
||||
});
|
||||
|
||||
// Group by date
|
||||
emails.forEach(email => {
|
||||
if (email.timestamp) {
|
||||
const date = email.timestamp.split('T')[0]; // Get date part
|
||||
analytics.byDate[date] = (analytics.byDate[date] || 0) + 1;
|
||||
}
|
||||
});
|
||||
|
||||
return analytics;
|
||||
} catch (error) {
|
||||
console.error('Get campaign analytics failed:', error);
|
||||
return {
|
||||
totalEmails: 0,
|
||||
smtpEmails: 0,
|
||||
mailtoClicks: 0,
|
||||
successfulEmails: 0,
|
||||
failedEmails: 0,
|
||||
byLevel: {},
|
||||
byDate: {},
|
||||
recentEmails: []
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new NocoDBService();
|
||||
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