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();