475 lines
15 KiB
JavaScript

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 {
// Map field names to NocoDB column titles
const mappedUpdates = {};
if (updates.slug !== undefined) mappedUpdates['Campaign Slug'] = updates.slug;
if (updates.title !== undefined) mappedUpdates['Campaign Title'] = updates.title;
if (updates.description !== undefined) mappedUpdates['Description'] = updates.description;
if (updates.email_subject !== undefined) mappedUpdates['Email Subject'] = updates.email_subject;
if (updates.email_body !== undefined) mappedUpdates['Email Body'] = updates.email_body;
if (updates.call_to_action !== undefined) mappedUpdates['Call to Action'] = updates.call_to_action;
if (updates.status !== undefined) mappedUpdates['Status'] = updates.status;
if (updates.allow_smtp_email !== undefined) mappedUpdates['Allow SMTP Email'] = updates.allow_smtp_email;
if (updates.allow_mailto_link !== undefined) mappedUpdates['Allow Mailto Link'] = updates.allow_mailto_link;
if (updates.collect_user_info !== undefined) mappedUpdates['Collect User Info'] = updates.collect_user_info;
if (updates.show_email_count !== undefined) mappedUpdates['Show Email Count'] = updates.show_email_count;
if (updates.target_government_levels !== undefined) mappedUpdates['Target Government Levels'] = updates.target_government_levels;
if (updates.updated_at !== undefined) mappedUpdates['UpdatedAt'] = updates.updated_at;
const url = `${this.getTableUrl(this.tableIds.campaigns)}/${id}`;
const response = await this.client.patch(url, mappedUpdates);
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();