1254 lines
46 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,
calls: process.env.NOCODB_TABLE_CALLS,
representativeResponses: process.env.NOCODB_TABLE_REPRESENTATIVE_RESPONSES,
responseUpvotes: process.env.NOCODB_TABLE_RESPONSE_UPVOTES,
emailVerifications: process.env.NOCODB_TABLE_EMAIL_VERIFICATIONS,
customRecipients: process.env.NOCODB_TABLE_CUSTOM_RECIPIENTS
};
// 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',
// Explicitly request all fields to ensure newly added columns are included
fields: '*'
});
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,
'Allow Custom Recipients': campaignData.allow_custom_recipients,
'Show Response Wall Button': campaignData.show_response_wall,
'Highlight Campaign': campaignData.highlight_campaign,
'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.allow_custom_recipients !== undefined) mappedUpdates['Allow Custom Recipients'] = updates.allow_custom_recipients;
if (updates.show_response_wall !== undefined) mappedUpdates['Show Response Wall Button'] = updates.show_response_wall;
if (updates.highlight_campaign !== undefined) mappedUpdates['Highlight Campaign'] = updates.highlight_campaign;
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;
}
}
// Get the currently highlighted campaign
async getHighlightedCampaign() {
try {
const response = await this.getAll(this.tableIds.campaigns, {
where: `(Highlight Campaign,eq,true)`,
limit: 1
});
return response.list && response.list.length > 0 ? response.list[0] : null;
} catch (error) {
console.error('Get highlighted campaign failed:', error);
throw error;
}
}
// Set a campaign as highlighted (and unset all others)
async setHighlightedCampaign(campaignId) {
try {
// First, unset any existing highlighted campaigns
const currentHighlighted = await this.getHighlightedCampaign();
if (currentHighlighted) {
const currentId = currentHighlighted.ID || currentHighlighted.Id || currentHighlighted.id;
if (currentId && currentId !== campaignId) {
await this.updateCampaign(currentId, { highlight_campaign: false });
}
}
// Then set the new highlighted campaign
await this.updateCampaign(campaignId, { highlight_campaign: true });
return { success: true };
} catch (error) {
console.error('Set highlighted campaign failed:', error);
throw error;
}
}
// Unset highlighted campaign
async unsetHighlightedCampaign(campaignId) {
try {
await this.updateCampaign(campaignId, { highlight_campaign: false });
return { success: true };
} catch (error) {
console.error('Unset highlighted 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 getCampaignCallCount(campaignId) {
try {
if (!this.tableIds.calls) {
console.warn('Calls table not configured, returning 0');
return 0;
}
const response = await this.getAll(this.tableIds.calls, {
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 call count failed:', error);
return 0;
}
}
async getCampaignVerifiedResponseCount(campaignId) {
try {
if (!this.tableIds.representativeResponses) {
console.warn('Representative responses table not configured, returning 0');
return 0;
}
// Get verified AND approved responses for this campaign
const response = await this.getAll(this.tableIds.representativeResponses, {
where: `(Campaign ID,eq,${campaignId})~and(Is Verified,eq,true)~and(Status,eq,approved)`,
limit: 1000 // Get enough to count
});
return response.pageInfo ? response.pageInfo.totalRows : (response.list ? response.list.length : 0);
} catch (error) {
console.error('Get campaign verified response 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);
}
// Representative Responses methods
async getRepresentativeResponses(params = {}) {
if (!this.tableIds.representativeResponses) {
throw new Error('Representative responses table not configured');
}
console.log('getRepresentativeResponses params:', JSON.stringify(params, null, 2));
const result = await this.getAll(this.tableIds.representativeResponses, params);
// Log without the where clause to see ALL responses
if (params.where) {
const allResult = await this.getAll(this.tableIds.representativeResponses, {});
console.log(`Total responses in DB (no filter): ${allResult.list?.length || 0}`);
if (allResult.list && allResult.list.length > 0) {
console.log('Sample raw response from DB:', JSON.stringify(allResult.list[0], null, 2));
}
}
console.log('getRepresentativeResponses raw result:', JSON.stringify(result, null, 2));
// NocoDB returns {list: [...]} or {pageInfo: {...}, list: [...]}
const list = result.list || [];
console.log(`getRepresentativeResponses: Found ${list.length} responses`);
return list.map(item => this.normalizeResponse(item));
}
async getRepresentativeResponseById(responseId) {
if (!this.tableIds.representativeResponses) {
throw new Error('Representative responses table not configured');
}
try {
const url = `${this.getTableUrl(this.tableIds.representativeResponses)}/${responseId}`;
const response = await this.client.get(url);
return this.normalizeResponse(response.data);
} catch (error) {
console.error('Error getting representative response by ID:', error);
throw error;
}
}
async createRepresentativeResponse(responseData) {
if (!this.tableIds.representativeResponses) {
throw new Error('Representative responses table not configured');
}
// Ensure campaign_id is not null/undefined
if (!responseData.campaign_id) {
throw new Error('Campaign ID is required for creating a response');
}
const data = {
'Campaign ID': responseData.campaign_id,
'Campaign Slug': responseData.campaign_slug,
'Representative Name': responseData.representative_name,
'Representative Title': responseData.representative_title,
'Representative Level': responseData.representative_level,
'Response Type': responseData.response_type,
'Response Text': responseData.response_text,
'User Comment': responseData.user_comment,
'Screenshot URL': responseData.screenshot_url,
'Submitted By Name': responseData.submitted_by_name,
'Submitted By Email': responseData.submitted_by_email,
'Submitted By User ID': responseData.submitted_by_user_id,
'Is Anonymous': responseData.is_anonymous,
'Status': responseData.status,
'Is Verified': responseData.is_verified,
'Representative Email': responseData.representative_email,
'Verification Token': responseData.verification_token,
'Verification Sent At': responseData.verification_sent_at,
'Verified At': responseData.verified_at,
'Verified By': responseData.verified_by,
'Upvote Count': responseData.upvote_count,
'Submitted IP': responseData.submitted_ip
};
console.log('Creating response with data:', JSON.stringify(data, null, 2));
const url = this.getTableUrl(this.tableIds.representativeResponses);
const response = await this.client.post(url, data);
return this.normalizeResponse(response.data);
}
async updateRepresentativeResponse(responseId, updates) {
if (!this.tableIds.representativeResponses) {
throw new Error('Representative responses table not configured');
}
const data = {};
if (updates.status !== undefined) data['Status'] = updates.status;
if (updates.is_verified !== undefined) data['Is Verified'] = updates.is_verified;
if (updates.upvote_count !== undefined) data['Upvote Count'] = updates.upvote_count;
if (updates.response_text !== undefined) data['Response Text'] = updates.response_text;
if (updates.user_comment !== undefined) data['User Comment'] = updates.user_comment;
if (updates.representative_email !== undefined) data['Representative Email'] = updates.representative_email;
if (updates.verification_token !== undefined) data['Verification Token'] = updates.verification_token;
if (updates.verification_sent_at !== undefined) data['Verification Sent At'] = updates.verification_sent_at;
if (updates.verified_at !== undefined) data['Verified At'] = updates.verified_at;
if (updates.verified_by !== undefined) data['Verified By'] = updates.verified_by;
console.log(`Updating response ${responseId} with data:`, JSON.stringify(data, null, 2));
const url = `${this.getTableUrl(this.tableIds.representativeResponses)}/${responseId}`;
const response = await this.client.patch(url, data);
console.log('NocoDB update response:', JSON.stringify(response.data, null, 2));
return this.normalizeResponse(response.data);
}
async deleteRepresentativeResponse(responseId) {
if (!this.tableIds.representativeResponses) {
throw new Error('Representative responses table not configured');
}
const url = `${this.getTableUrl(this.tableIds.representativeResponses)}/${responseId}`;
const response = await this.client.delete(url);
return response.data;
}
// Response Upvotes methods
async getResponseUpvotes(params = {}) {
if (!this.tableIds.responseUpvotes) {
throw new Error('Response upvotes table not configured');
}
const result = await this.getAll(this.tableIds.responseUpvotes, params);
// NocoDB returns {list: [...]} or {pageInfo: {...}, list: [...]}
const list = result.list || [];
return list.map(item => this.normalizeUpvote(item));
}
async createResponseUpvote(upvoteData) {
if (!this.tableIds.responseUpvotes) {
throw new Error('Response upvotes table not configured');
}
const data = {
'Response ID': upvoteData.response_id,
'User ID': upvoteData.user_id,
'User Email': upvoteData.user_email,
'Upvoted IP': upvoteData.upvoted_ip
};
const url = this.getTableUrl(this.tableIds.responseUpvotes);
const response = await this.client.post(url, data);
return this.normalizeUpvote(response.data);
}
async deleteResponseUpvote(upvoteId) {
if (!this.tableIds.responseUpvotes) {
throw new Error('Response upvotes table not configured');
}
const url = `${this.getTableUrl(this.tableIds.responseUpvotes)}/${upvoteId}`;
const response = await this.client.delete(url);
return response.data;
}
// Normalize response data from NocoDB format to application format
normalizeResponse(data) {
if (!data) return null;
return {
id: data.ID || data.Id || data.id,
campaign_id: data['Campaign ID'] || data.campaign_id,
campaign_slug: data['Campaign Slug'] || data.campaign_slug,
representative_name: data['Representative Name'] || data.representative_name,
representative_title: data['Representative Title'] || data.representative_title,
representative_level: data['Representative Level'] || data.representative_level,
response_type: data['Response Type'] || data.response_type,
response_text: data['Response Text'] || data.response_text,
user_comment: data['User Comment'] || data.user_comment,
screenshot_url: data['Screenshot URL'] || data.screenshot_url,
submitted_by_name: data['Submitted By Name'] || data.submitted_by_name,
submitted_by_email: data['Submitted By Email'] || data.submitted_by_email,
submitted_by_user_id: data['Submitted By User ID'] || data.submitted_by_user_id,
is_anonymous: data['Is Anonymous'] || data.is_anonymous || false,
status: data['Status'] || data.status,
is_verified: data['Is Verified'] || data.is_verified || false,
representative_email: data['Representative Email'] || data.representative_email,
verification_token: data['Verification Token'] || data.verification_token,
verification_sent_at: data['Verification Sent At'] || data.verification_sent_at,
verified_at: data['Verified At'] || data.verified_at,
verified_by: data['Verified By'] || data.verified_by,
upvote_count: data['Upvote Count'] || data.upvote_count || 0,
submitted_ip: data['Submitted IP'] || data.submitted_ip,
created_at: data.CreatedAt || data.created_at,
updated_at: data.UpdatedAt || data.updated_at
};
}
// Normalize upvote data from NocoDB format to application format
normalizeUpvote(data) {
if (!data) return null;
return {
id: data.ID || data.Id || data.id,
response_id: data['Response ID'] || data.response_id,
user_id: data['User ID'] || data.user_id,
user_email: data['User Email'] || data.user_email,
upvoted_ip: data['Upvoted IP'] || data.upvoted_ip,
created_at: data.CreatedAt || data.created_at
};
}
// Email verification methods
async createEmailVerification(verificationData) {
if (!this.tableIds.emailVerifications) {
throw new Error('Email verifications table not configured');
}
const data = {
'Token': verificationData.token,
'Email': verificationData.email,
'Temp Campaign Data': verificationData.temp_campaign_data,
'Created At': verificationData.created_at,
'Expires At': verificationData.expires_at,
'Used': verificationData.used || false
};
return await this.create(this.tableIds.emailVerifications, data);
}
async getEmailVerificationByToken(token) {
if (!this.tableIds.emailVerifications) {
throw new Error('Email verifications table not configured');
}
try {
const response = await this.getAll(this.tableIds.emailVerifications, {
where: `(Token,eq,${token})`,
limit: 1
});
return response.list?.[0] || null;
} catch (error) {
console.error('Error in getEmailVerificationByToken:', error.message);
throw error;
}
}
async updateEmailVerification(verificationId, updateData) {
if (!this.tableIds.emailVerifications) {
throw new Error('Email verifications table not configured');
}
const data = {};
if (updateData.used !== undefined) {
data['Used'] = updateData.used;
}
return await this.update(this.tableIds.emailVerifications, verificationId, data);
}
async deleteExpiredEmailVerifications() {
if (!this.tableIds.emailVerifications) {
throw new Error('Email verifications table not configured');
}
try {
const now = new Date().toISOString();
const response = await this.getAll(this.tableIds.emailVerifications, {
where: `(Expires At,lt,${now})`
});
if (response.list && response.list.length > 0) {
for (const verification of response.list) {
const id = verification.ID || verification.Id || verification.id;
if (id) {
await this.client.delete(`${this.getTableUrl(this.tableIds.emailVerifications)}/${id}`);
}
}
return { success: true, deletedCount: response.list.length };
}
return { success: true, deletedCount: 0 };
} catch (error) {
console.error('Error deleting expired email verifications:', error.message);
return { success: false, error: error.message };
}
}
// ===== Custom Recipients Methods =====
/**
* Get all custom recipients for a campaign
*/
async getCustomRecipients(campaignId) {
if (!this.tableIds.customRecipients) {
throw new Error('Custom recipients table not configured');
}
try {
const response = await this.getAll(this.tableIds.customRecipients, {
where: `(Campaign ID,eq,${campaignId})`,
sort: '-CreatedAt',
limit: 1000
});
if (!response.list || response.list.length === 0) {
return [];
}
// Normalize the data structure
return response.list.map(record => ({
id: record.ID || record.Id || record.id,
campaign_id: record['Campaign ID'],
campaign_slug: record['Campaign Slug'],
recipient_name: record['Recipient Name'],
recipient_email: record['Recipient Email'],
recipient_title: record['Recipient Title'] || null,
recipient_organization: record['Recipient Organization'] || null,
notes: record['Notes'] || null,
is_active: record['Is Active'] !== false, // Default to true
created_at: record.CreatedAt,
updated_at: record.UpdatedAt
}));
} catch (error) {
console.error('Error in getCustomRecipients:', error.message);
throw error;
}
}
/**
* Get custom recipients by campaign slug
*/
async getCustomRecipientsBySlug(campaignSlug) {
if (!this.tableIds.customRecipients) {
throw new Error('Custom recipients table not configured');
}
try {
const response = await this.getAll(this.tableIds.customRecipients, {
where: `(Campaign Slug,eq,${campaignSlug})`,
sort: '-CreatedAt',
limit: 1000
});
if (!response.list || response.list.length === 0) {
return [];
}
// Normalize the data structure
return response.list.map(record => ({
id: record.ID || record.Id || record.id,
campaign_id: record['Campaign ID'],
campaign_slug: record['Campaign Slug'],
recipient_name: record['Recipient Name'],
recipient_email: record['Recipient Email'],
recipient_title: record['Recipient Title'] || null,
recipient_organization: record['Recipient Organization'] || null,
notes: record['Notes'] || null,
is_active: record['Is Active'] !== false, // Default to true
created_at: record.CreatedAt,
updated_at: record.UpdatedAt
}));
} catch (error) {
console.error('Error in getCustomRecipientsBySlug:', error.message);
throw error;
}
}
/**
* Create a new custom recipient
*/
async createCustomRecipient(recipientData) {
if (!this.tableIds.customRecipients) {
throw new Error('Custom recipients table not configured');
}
const data = {
'Campaign ID': recipientData.campaign_id,
'Campaign Slug': recipientData.campaign_slug,
'Recipient Name': recipientData.recipient_name,
'Recipient Email': recipientData.recipient_email,
'Recipient Title': recipientData.recipient_title || null,
'Recipient Organization': recipientData.recipient_organization || null,
'Notes': recipientData.notes || null,
'Is Active': recipientData.is_active !== false // Default to true
};
const created = await this.create(this.tableIds.customRecipients, data);
// Return normalized data
return {
id: created.ID || created.Id || created.id,
campaign_id: created['Campaign ID'],
campaign_slug: created['Campaign Slug'],
recipient_name: created['Recipient Name'],
recipient_email: created['Recipient Email'],
recipient_title: created['Recipient Title'] || null,
recipient_organization: created['Recipient Organization'] || null,
notes: created['Notes'] || null,
is_active: created['Is Active'] !== false,
created_at: created.CreatedAt,
updated_at: created.UpdatedAt
};
}
/**
* Update a custom recipient
*/
async updateCustomRecipient(recipientId, updateData) {
if (!this.tableIds.customRecipients) {
throw new Error('Custom recipients table not configured');
}
const data = {};
if (updateData.recipient_name !== undefined) {
data['Recipient Name'] = updateData.recipient_name;
}
if (updateData.recipient_email !== undefined) {
data['Recipient Email'] = updateData.recipient_email;
}
if (updateData.recipient_title !== undefined) {
data['Recipient Title'] = updateData.recipient_title;
}
if (updateData.recipient_organization !== undefined) {
data['Recipient Organization'] = updateData.recipient_organization;
}
if (updateData.notes !== undefined) {
data['Notes'] = updateData.notes;
}
if (updateData.is_active !== undefined) {
data['Is Active'] = updateData.is_active;
}
const updated = await this.update(this.tableIds.customRecipients, recipientId, data);
// Return normalized data
return {
id: updated.ID || updated.Id || updated.id,
campaign_id: updated['Campaign ID'],
campaign_slug: updated['Campaign Slug'],
recipient_name: updated['Recipient Name'],
recipient_email: updated['Recipient Email'],
recipient_title: updated['Recipient Title'] || null,
recipient_organization: updated['Recipient Organization'] || null,
notes: updated['Notes'] || null,
is_active: updated['Is Active'] !== false,
created_at: updated.CreatedAt,
updated_at: updated.UpdatedAt
};
}
/**
* Delete a custom recipient
*/
async deleteCustomRecipient(recipientId) {
if (!this.tableIds.customRecipients) {
throw new Error('Custom recipients table not configured');
}
try {
await this.client.delete(`${this.getTableUrl(this.tableIds.customRecipients)}/${recipientId}`);
return true;
} catch (error) {
if (error.response?.status === 404) {
return false;
}
throw error;
}
}
/**
* Delete all custom recipients for a campaign
*/
async deleteCustomRecipientsByCampaign(campaignId) {
if (!this.tableIds.customRecipients) {
throw new Error('Custom recipients table not configured');
}
try {
const recipients = await this.getCustomRecipients(campaignId);
let deletedCount = 0;
for (const recipient of recipients) {
const deleted = await this.deleteCustomRecipient(recipient.id);
if (deleted) deletedCount++;
}
return deletedCount;
} catch (error) {
console.error('Error deleting custom recipients by campaign:', error.message);
throw error;
}
}
}
module.exports = new NocoDBService();