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