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 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); const response = await this.client.post(url, cleanData); return response.data; } catch (error) { console.error('Error creating record:', error); 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 = []; 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 || '', '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, '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; } } // 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, '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 { // 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 }; const response = await this.create(this.tableIds.campaignEmails, mappedData); 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'] || 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'); } 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();