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 the data to remove any null values and system fields that NocoDB manages const cleanData = Object.keys(data).reduce((clean, key) => { // Skip null/undefined values if (data[key] === null || data[key] === undefined) { return clean; } // Skip any potential ID or system fields that NocoDB manages automatically const systemFields = ['id', 'Id', 'ID', 'CreatedAt', 'UpdatedAt', 'created_at', 'updated_at']; if (systemFields.includes(key)) { console.log(`Skipping system field: ${key}`); return clean; } clean[key] = data[key]; return clean; }, {}); console.log(`Creating record in table ${tableId} with data:`, JSON.stringify(cleanData, null, 2)); const url = this.getTableUrl(tableId); const response = await this.client.post(url, cleanData); console.log(`Record created successfully in table ${tableId}`); return response.data; } catch (error) { console.error(`Error creating record in table ${tableId}:`, error.message); if (error.response?.data) { console.error('Full error response:', JSON.stringify(error.response.data, null, 2)); } throw error; } } // Update record async update(tableId, recordId, data) { try { // Clean the data to remove any null values which can cause NocoDB issues const cleanData = Object.keys(data).reduce((clean, key) => { if (data[key] !== null && data[key] !== undefined) { clean[key] = data[key]; } return clean; }, {}); const url = `${this.getTableUrl(tableId)}/${recordId}`; const response = await this.client.patch(url, cleanData); return response.data; } catch (error) { console.error('Error updating record:', error); throw error; } } async storeRepresentatives(postalCode, representatives) { try { const stored = []; console.log(`Attempting to store ${representatives.length} representatives for postal code ${postalCode}`); // First, clear any existing representatives for this postal code to avoid duplicates try { const existingQuery = await this.getAll(this.tableIds.representatives, { where: `(Postal Code,eq,${postalCode})` }); if (existingQuery.list && existingQuery.list.length > 0) { console.log(`Found ${existingQuery.list.length} existing representatives for ${postalCode}, using cached data`); return { success: true, count: existingQuery.list.length, cached: true }; } } catch (checkError) { console.log('Could not check for existing representatives:', checkError.message); // Continue anyway } // Store each representative, handling duplicates gracefully 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 || '', 'Profile URL': rep.url || '', 'Photo URL': rep.photo_url || '', 'Offices': rep.offices ? JSON.stringify(rep.offices) : '[]', 'Cached At': new Date().toISOString() }; try { const result = await this.create(this.tableIds.representatives, record); stored.push(result); console.log(`Successfully stored representative: ${rep.name}`); } catch (createError) { // Handle any duplicate or constraint errors gracefully if (createError.response?.status === 400) { console.log(`Skipping representative ${rep.name} due to constraint: ${createError.response?.data?.message || createError.message}`); // Continue to next representative without failing } else { console.log(`Error storing representative ${rep.name}:`, createError.message); // For non-400 errors, we might want to continue or fail - let's continue for now } } } console.log(`Successfully stored ${stored.length} out of ${representatives.length} representatives for ${postalCode}`); return { success: true, count: stored.length }; } catch (error) { // Catch-all error handler - never let this method throw console.log('Error in storeRepresentatives:', error.response?.data || error.message); return { success: false, error: error.message, count: 0 }; } } 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})` }); const cachedRecords = response.list || []; // Transform NocoDB format back to API format const transformedRecords = cachedRecords.map(record => ({ name: record['Name'], email: record['Email'], district_name: record['District Name'], elected_office: record['Elected Office'], party_name: record['Party Name'], representative_set_name: record['Representative Set Name'], url: record['Profile URL'], photo_url: record['Photo URL'], offices: record['Offices'] ? JSON.parse(record['Offices']) : [] })); return transformedRecords; } 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, 'Message': emailData.message || '', 'Postal Code': emailData.postalCode, 'Status': emailData.status, 'Sent At': emailData.timestamp, 'Sender IP': emailData.senderIP || null // Add IP tracking for rate limiting }; await this.create(this.tableIds.emails, record); return { success: true }; } catch (error) { console.error('Error logging email:', error); throw error; } } async logEmailPreview(previewData) { try { // Let NocoDB handle all ID generation - just provide the basic data const record = { 'Recipient Email': previewData.recipientEmail, 'Sender Name': previewData.senderName, 'Sender Email': previewData.senderEmail, 'Subject': previewData.subject, 'Message': previewData.message || '', 'Postal Code': previewData.postalCode, 'Status': 'previewed', 'Sent At': new Date().toISOString(), // Simple timestamp, let NocoDB handle uniqueness 'Sender IP': previewData.senderIP || 'unknown' }; console.log('Attempting to log email preview...'); await this.create(this.tableIds.emails, record); console.log('Email preview logged successfully'); return { success: true }; } catch (error) { console.error('Error logging email preview:', error); // Check if it's a duplicate record error if (error.response && error.response.data && error.response.data.code === '23505') { console.warn('Duplicate constraint violation - this suggests NocoDB has hidden unique constraints'); console.warn('Skipping preview log to avoid breaking the preview functionality'); return { success: true, warning: 'Duplicate preview log skipped due to constraint' }; } // Don't throw error - preview logging is optional and shouldn't break the preview console.warn('Preview logging failed but continuing with preview functionality'); return { success: false, error: error.message }; } } // Check if an email was recently sent to this recipient from this IP async checkRecentEmailSend(senderIP, recipientEmail, windowMinutes = 5) { try { const windowStart = new Date(Date.now() - (windowMinutes * 60 * 1000)).toISOString(); const params = { where: `(Sender IP,eq,${senderIP})~and(Recipient Email,eq,${recipientEmail})~and(Sent At,gte,${windowStart})`, sort: '-CreatedAt', limit: 1 }; const response = await this.getAll(this.tableIds.emails, params); return response.list && response.list.length > 0 ? response.list[0] : null; } catch (error) { console.error('Error checking recent email send:', error); return null; // On error, allow the send (fallback to in-memory limiter) } } 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 { // Map fields to NocoDB column titles const mappedData = { 'Postal Code': postalCodeData.postal_code, 'City': postalCodeData.city, 'Province': postalCodeData.province }; const response = await this.create(this.tableIds.postalCodes, mappedData); 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, 'Cover Photo': campaignData.cover_photo, '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, 'Allow Email Editing': campaignData.allow_email_editing, 'Target Government Levels': campaignData.target_government_levels, 'Created By User ID': campaignData.created_by_user_id, 'Created By User Email': campaignData.created_by_user_email, 'Created By User Name': campaignData.created_by_user_name }; 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.cover_photo !== undefined) mappedUpdates['Cover Photo'] = updates.cover_photo; 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.allow_email_editing !== undefined) mappedUpdates['Allow Email Editing'] = updates.allow_email_editing; 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 { // Map fields to NocoDB column titles const mappedData = { 'Campaign ID': emailData.campaign_id, 'Campaign Slug': emailData.campaign_slug, 'User Email': emailData.user_email, 'User Name': emailData.user_name, 'User Postal Code': emailData.user_postal_code, 'Recipient Email': emailData.recipient_email, 'Recipient Name': emailData.recipient_name, 'Recipient Title': emailData.recipient_title, 'Government Level': emailData.recipient_level, 'Email Method': emailData.email_method, 'Subject': emailData.subject, 'Message': emailData.message, 'Status': emailData.status // Note: 'Sent At' has default value of now() so we don't need to set it }; try { const response = await this.create(this.tableIds.campaignEmails, mappedData); return response; } catch (createError) { // Handle duplicate record errors gracefully if (createError.response?.status === 400 && (createError.response?.data?.message?.includes('already exists') || createError.response?.data?.code === '23505')) { console.log(`Campaign email log already exists for user ${emailData.user_email} and campaign ${emailData.campaign_slug}, skipping...`); // Return a success response to indicate the logging was handled return { success: true, duplicate: true }; } else { // Re-throw other errors throw createError; } } } catch (error) { console.error('Log campaign email failed:', error.response?.data || error.message); // Return a failure response but don't throw - logging should not break the main flow return { success: false, error: error.message }; } } 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'] || e.email_method) === 'smtp').length, mailtoClicks: emails.filter(e => (e['Email Method'] || e.email_method) === 'mailto').length, successfulEmails: emails.filter(e => { const status = e['Status'] || e.status; return status === 'sent' || status === 'clicked'; }).length, failedEmails: emails.filter(e => (e['Status'] || e.status) === 'failed').length, byLevel: {}, byDate: {}, recentEmails: emails.slice(0, 10).map(email => ({ timestamp: email['Sent At'] || email.timestamp || email.sent_at, user_name: email['User Name'] || email.user_name, recipient_name: email['Recipient Name'] || email.recipient_name, recipient_level: email['Government Level'] || email.recipient_level, email_method: email['Email Method'] || email.email_method, status: email['Status'] || email.status })) }; // Group by government level emails.forEach(email => { const level = email['Government Level'] || email.recipient_level || 'Other'; analytics.byLevel[level] = (analytics.byLevel[level] || 0) + 1; }); // Group by date emails.forEach(email => { const timestamp = email['Sent At'] || email.timestamp || email.sent_at; if (timestamp) { const date = 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'); } try { const response = await this.getAll(this.tableIds.users, { where: `(Email,eq,${email})`, limit: 1 }); return response.list?.[0] || null; } catch (error) { console.error('Error in getUserByEmail:', error.message); throw error; } } 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'); } const url = `${this.getTableUrl(this.tableIds.users)}/${userId}`; const response = await this.client.delete(url); return response.data; } async getById(tableId, recordId) { try { const url = `${this.getTableUrl(tableId)}/${recordId}`; const response = await this.client.get(url); return response.data; } catch (error) { console.error('Error getting record by ID:', error); throw error; } } 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();