From d29ffa6300870cc8dfee53f1654d7bc87fb9103e Mon Sep 17 00:00:00 2001 From: admin Date: Wed, 24 Sep 2025 12:37:26 -0600 Subject: [PATCH] Bunch of buag fixes and updates --- influence/README.MD | 4 +- influence/app/controllers/authController.js | 21 +- influence/app/controllers/campaigns.js | 103 ++- influence/app/controllers/emails.js | 28 +- influence/app/controllers/representatives.js | 29 +- influence/app/public/admin.html | 8 + influence/app/public/campaign.html | 59 +- influence/app/public/css/styles.css | 540 +++++++++++++- influence/app/public/email-test.html | 2 +- influence/app/public/index.html | 134 +++- influence/app/public/js/admin.js | 2 + influence/app/public/js/api-client.js | 5 + influence/app/public/js/campaign.js | 147 +++- influence/app/public/js/email-composer.js | 182 ++++- influence/app/public/js/email-testing.js | 2 +- influence/app/public/js/main.js | 33 +- influence/app/public/js/postal-lookup.js | 3 +- .../app/public/js/representatives-display.js | 235 +++++- .../app/public/js/representatives-map.js | 583 +++++++++++++++ influence/app/public/terms.html | 230 ++++++ influence/app/routes/api.js | 28 +- influence/app/server.js | 10 +- influence/app/services/email.js | 10 +- influence/app/services/emailTemplates.js | 2 +- influence/app/services/nocodb.js | 155 +++- .../email/representative-contact.html | 12 +- .../email/representative-contact.txt | 6 +- influence/app/utils/rate-limiter.js | 17 +- influence/example.env | 4 +- influence/files-explainer.md | 4 +- influence/influence-campaign-setup.md | 10 +- influence/instruct.md | 2 +- influence/scripts/build-nocodb.sh | 675 +++++++++++++++++- 33 files changed, 3085 insertions(+), 200 deletions(-) create mode 100644 influence/app/public/js/representatives-map.js create mode 100644 influence/app/public/terms.html diff --git a/influence/README.MD b/influence/README.MD index 1b0b472..546c64d 100644 --- a/influence/README.MD +++ b/influence/README.MD @@ -150,7 +150,7 @@ SMTP_SECURE=false SMTP_USER=test SMTP_PASS=test SMTP_FROM_EMAIL=dev@albertainfluence.local -SMTP_FROM_NAME="Alberta Influence Campaign (DEV)" +SMTP_FROM_NAME="BNKops Influence Campaign (DEV)" # Email Testing TEST_EMAIL_RECIPIENT=developer@example.com @@ -182,7 +182,7 @@ SMTP_PORT=587 SMTP_SECURE=false SMTP_USER=your_email@gmail.com SMTP_PASS=your_app_password -SMTP_FROM_NAME=Alberta Influence Campaign +SMTP_FROM_NAME=BNKops Influence Campaign SMTP_FROM_EMAIL=your_email@gmail.com # Rate Limiting diff --git a/influence/app/controllers/authController.js b/influence/app/controllers/authController.js index f94fc7e..05f8c64 100644 --- a/influence/app/controllers/authController.js +++ b/influence/app/controllers/authController.js @@ -50,10 +50,23 @@ class AuthController { // Update last login time try { - const userId = user.Id || user.id; - await nocodbService.updateUser(userId, { - 'Last Login': new Date().toISOString() + // Debug: Log user object structure + console.log('User object keys:', Object.keys(user)); + console.log('User ID candidates:', { + ID: user.ID, + Id: user.Id, + id: user.id }); + + const userId = user.ID || user.Id || user.id; + + if (userId) { + await nocodbService.updateUser(userId, { + 'Last Login': new Date().toISOString() + }); + } else { + console.warn('No valid user ID found for updating last login time'); + } } catch (updateError) { console.warn('Failed to update last login time:', updateError.message); // Don't fail the login @@ -61,7 +74,7 @@ class AuthController { // Set session req.session.authenticated = true; - req.session.userId = user.Id || user.id; + req.session.userId = user.ID || user.Id || user.id; req.session.userEmail = user.Email || user.email; req.session.userName = user.Name || user.name; req.session.isAdmin = user.Admin || user.admin || false; diff --git a/influence/app/controllers/campaigns.js b/influence/app/controllers/campaigns.js index ce169df..06eb5d1 100644 --- a/influence/app/controllers/campaigns.js +++ b/influence/app/controllers/campaigns.js @@ -3,6 +3,30 @@ const emailService = require('../services/email'); const representAPI = require('../services/represent-api'); const { generateSlug, validateSlug } = require('../utils/validators'); +// Helper function to cache representatives +async function cacheRepresentatives(postalCode, representatives, representData) { + try { + // Cache the postal code info + await nocoDB.storePostalCodeInfo({ + postal_code: postalCode, + city: representData.city, + province: representData.province + }); + + // Cache representatives using the existing method + const result = await nocoDB.storeRepresentatives(postalCode, representatives); + + if (result.success) { + console.log(`Successfully cached ${result.count} representatives for ${postalCode}`); + } else { + console.log(`Partial success caching representatives for ${postalCode}: ${result.error || 'unknown error'}`); + } + } catch (error) { + console.log(`Failed to cache representatives for ${postalCode}:`, error.message); + // Don't throw - caching is optional and should never break the main flow + } +} + class CampaignsController { // Get all campaigns (for admin panel) async getAllCampaigns(req, res, next) { @@ -31,6 +55,7 @@ class CampaignsController { allow_mailto_link: campaign['Allow Mailto Link'] || campaign.allow_mailto_link, collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info, show_email_count: campaign['Show Email Count'] || campaign.show_email_count, + allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing, target_government_levels: campaign['Target Government Levels'] || campaign.target_government_levels, created_at: campaign.CreatedAt || campaign.created_at, updated_at: campaign.UpdatedAt || campaign.updated_at, @@ -91,6 +116,7 @@ class CampaignsController { allow_mailto_link: campaign['Allow Mailto Link'] || campaign.allow_mailto_link, collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info, show_email_count: campaign['Show Email Count'] || campaign.show_email_count, + allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing, target_government_levels: campaign['Target Government Levels'] || campaign.target_government_levels, created_at: campaign.CreatedAt || campaign.created_at, updated_at: campaign.UpdatedAt || campaign.updated_at, @@ -153,6 +179,7 @@ class CampaignsController { allow_mailto_link: campaign['Allow Mailto Link'] || campaign.allow_mailto_link, collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info, show_email_count: campaign['Show Email Count'] || campaign.show_email_count, + allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing, target_government_levels: Array.isArray(campaign['Target Government Levels'] || campaign.target_government_levels) ? (campaign['Target Government Levels'] || campaign.target_government_levels) : (typeof (campaign['Target Government Levels'] || campaign.target_government_levels) === 'string' && (campaign['Target Government Levels'] || campaign.target_government_levels).length > 0 @@ -181,10 +208,12 @@ class CampaignsController { email_subject, email_body, call_to_action, + status = 'draft', allow_smtp_email = true, allow_mailto_link = true, collect_user_info = true, show_email_count = true, + allow_email_editing = false, target_government_levels = ['Federal', 'Provincial', 'Municipal'] } = req.body; @@ -206,11 +235,12 @@ class CampaignsController { email_subject, email_body, call_to_action, - status: 'draft', + status, allow_smtp_email, allow_mailto_link, collect_user_info, show_email_count, + allow_email_editing, // NocoDB MultiSelect expects an array of values target_government_levels: Array.isArray(target_government_levels) ? target_government_levels @@ -237,6 +267,7 @@ class CampaignsController { allow_mailto_link: campaign['Allow Mailto Link'] || campaign.allow_mailto_link, collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info, show_email_count: campaign['Show Email Count'] || campaign.show_email_count, + allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing, target_government_levels: campaign['Target Government Levels'] || campaign.target_government_levels, created_at: campaign.CreatedAt || campaign.created_at, updated_at: campaign.UpdatedAt || campaign.updated_at @@ -356,7 +387,9 @@ class CampaignsController { recipientName, recipientTitle, recipientLevel, - emailMethod = 'smtp' + emailMethod = 'smtp', + customEmailSubject, + customEmailBody } = req.body; // Get campaign @@ -392,8 +425,14 @@ class CampaignsController { }); } - const subject = campaign['Email Subject'] || campaign.email_subject; - const message = campaign['Email Body'] || campaign.email_body; + // Use custom email content if provided (when email editing is enabled), otherwise use campaign defaults + const allowEmailEditing = campaign['Allow Email Editing'] || campaign.allow_email_editing; + const subject = (allowEmailEditing && customEmailSubject) + ? customEmailSubject + : (campaign['Email Subject'] || campaign.email_subject); + const message = (allowEmailEditing && customEmailBody) + ? customEmailBody + : (campaign['Email Body'] || campaign.email_body); let emailResult = { success: true }; @@ -539,20 +578,46 @@ class CampaignsController { }); } - // Get representatives - const result = await representAPI.getRepresentativesByPostalCode(postalCode); - - // Process representatives from both concordance and centroid + // First check cache for representatives + const formattedPostalCode = postalCode.replace(/\s/g, '').toUpperCase(); let representatives = []; + let result = null; - // Add concordance representatives (if any) - if (result.representatives_concordance && result.representatives_concordance.length > 0) { - representatives = representatives.concat(result.representatives_concordance); + // Try to check cached data first, but don't fail if NocoDB is down + let cachedData = []; + try { + cachedData = await nocoDB.getRepresentativesByPostalCode(formattedPostalCode); + console.log(`Cache check for ${formattedPostalCode}: found ${cachedData.length} records`); + + if (cachedData && cachedData.length > 0) { + representatives = cachedData; + console.log(`Using cached representatives for ${formattedPostalCode}`); + } + } catch (cacheError) { + console.log(`Cache unavailable for ${formattedPostalCode}, proceeding with API call:`, cacheError.message); } - - // Add centroid representatives (if any) - these are the actual elected officials - if (result.representatives_centroid && result.representatives_centroid.length > 0) { - representatives = representatives.concat(result.representatives_centroid); + + // If not in cache, fetch from Represent API + if (representatives.length === 0) { + console.log(`Fetching representatives from Represent API for ${formattedPostalCode}`); + result = await representAPI.getRepresentativesByPostalCode(postalCode); + + // Process representatives from both concordance and centroid + // Add concordance representatives (if any) + if (result.representatives_concordance && result.representatives_concordance.length > 0) { + representatives = representatives.concat(result.representatives_concordance); + } + + // Add centroid representatives (if any) - these are the actual elected officials + if (result.representatives_centroid && result.representatives_centroid.length > 0) { + representatives = representatives.concat(result.representatives_centroid); + } + + // Cache the results if we got them from the API + if (representatives.length > 0 && result) { + console.log(`Attempting to cache ${representatives.length} representatives for ${formattedPostalCode}`); + await cacheRepresentatives(formattedPostalCode, representatives, result); + } } if (representatives.length === 0) { @@ -561,8 +626,8 @@ class CampaignsController { message: 'No representatives found for this postal code', representatives: [], location: { - city: result.city, - province: result.province + city: result?.city || 'Alberta', + province: result?.province || 'AB' } }); } @@ -602,8 +667,8 @@ class CampaignsController { success: true, representatives: filteredRepresentatives, location: { - city: result.city, - province: result.province + city: result?.city || cachedData[0]?.city || 'Alberta', + province: result?.province || cachedData[0]?.province || 'AB' } }); diff --git a/influence/app/controllers/emails.js b/influence/app/controllers/emails.js index caf2af7..84ac4b2 100644 --- a/influence/app/controllers/emails.js +++ b/influence/app/controllers/emails.js @@ -4,7 +4,7 @@ const nocoDB = require('../services/nocodb'); class EmailsController { async sendEmail(req, res, next) { try { - const { recipientEmail, senderName, senderEmail, subject, message, postalCode } = req.body; + const { recipientEmail, senderName, senderEmail, subject, message, postalCode, recipientName } = req.body; // Send the email using template system const emailResult = await emailService.sendRepresentativeEmail( @@ -13,7 +13,8 @@ class EmailsController { senderEmail, subject, message, - postalCode + postalCode, + recipientName ); // Log the email send event @@ -22,6 +23,7 @@ class EmailsController { senderName, senderEmail, subject, + message, postalCode, status: emailResult.success ? 'sent' : 'failed', timestamp: new Date().toISOString(), @@ -53,13 +55,14 @@ class EmailsController { async previewEmail(req, res, next) { try { - const { recipientEmail, subject, message, senderName, senderEmail, postalCode } = req.body; + const { recipientEmail, subject, message, senderName, senderEmail, postalCode, recipientName } = req.body; const templateVariables = { MESSAGE: message, SENDER_NAME: senderName || 'Anonymous', SENDER_EMAIL: senderEmail || 'unknown@example.com', - POSTAL_CODE: postalCode || 'Unknown' + POSTAL_CODE: postalCode || 'Unknown', + RECIPIENT_NAME: recipientName || 'Representative' }; const emailOptions = { @@ -74,6 +77,23 @@ class EmailsController { const preview = await emailService.previewTemplatedEmail('representative-contact', templateVariables, emailOptions); + // Log the email preview event (non-blocking) + try { + await nocoDB.logEmailPreview({ + recipientEmail, + senderName, + senderEmail, + subject, + message, + postalCode, + timestamp: new Date().toISOString(), + senderIP: req.ip || req.connection.remoteAddress + }); + } catch (loggingError) { + console.error('Failed to log email preview:', loggingError); + // Don't fail the preview if logging fails + } + res.json({ success: true, preview: preview, diff --git a/influence/app/controllers/representatives.js b/influence/app/controllers/representatives.js index a977ba9..675cd37 100644 --- a/influence/app/controllers/representatives.js +++ b/influence/app/controllers/representatives.js @@ -12,12 +12,16 @@ async function cacheRepresentatives(postalCode, representatives, representData) }); // Cache representatives using the existing method - await nocoDB.storeRepresentatives(postalCode, representatives); + const result = await nocoDB.storeRepresentatives(postalCode, representatives); - console.log(`Successfully cached representatives for ${postalCode}`); + if (result.success) { + console.log(`Successfully cached ${result.count} representatives for ${postalCode}`); + } else { + console.log(`Partial success caching representatives for ${postalCode}: ${result.error || 'unknown error'}`); + } } catch (error) { console.log(`Failed to cache representatives for ${postalCode}:`, error.message); - // Don't throw - caching is optional + // Don't throw - caching is optional and should never break the main flow } } @@ -48,9 +52,16 @@ class RepresentativesController { if (cachedData && cachedData.length > 0) { return res.json({ - source: 'cache', - postalCode: formattedPostalCode, - representatives: cachedData + success: true, + source: 'Local Cache', + data: { + postalCode: formattedPostalCode, + location: { + city: cachedData[0]?.city || 'Alberta', + province: 'AB' + }, + representatives: cachedData + } }); } } catch (cacheError) { @@ -85,6 +96,9 @@ class RepresentativesController { if (representData.representatives_centroid && representData.representatives_centroid.length > 0) { representatives = representatives.concat(representData.representatives_centroid); } + + // Representatives already include office information, no need for additional API calls + console.log('Using representatives data with existing office information'); console.log(`Representatives concordance count: ${representData.boundaries_concordance ? representData.boundaries_concordance.length : 0}`); console.log(`Representatives centroid count: ${representData.representatives_centroid ? representData.representatives_centroid.length : 0}`); @@ -111,6 +125,7 @@ class RepresentativesController { res.json({ success: true, + source: 'Open North', data: { postalCode, location: { @@ -151,7 +166,7 @@ class RepresentativesController { await nocoDB.storeRepresentatives(formattedPostalCode, representData.representatives_concordance); res.json({ - source: 'refreshed', + source: 'Open North', postalCode: formattedPostalCode, representatives: representData.representatives_concordance, city: representData.city, diff --git a/influence/app/public/admin.html b/influence/app/public/admin.html index fca4970..d2ccae9 100644 --- a/influence/app/public/admin.html +++ b/influence/app/public/admin.html @@ -588,6 +588,10 @@ Sincerely, +
+ + +
@@ -681,6 +685,10 @@ Sincerely, +
+ + +
diff --git a/influence/app/public/campaign.html b/influence/app/public/campaign.html index afc6ec4..9b34ee8 100644 --- a/influence/app/public/campaign.html +++ b/influence/app/public/campaign.html @@ -83,6 +83,43 @@ white-space: pre-wrap; } + .email-edit-subject, .email-edit-body { + width: 100%; + border: 1px solid #ced4da; + border-radius: 4px; + padding: 0.5rem; + font-family: inherit; + margin-bottom: 1rem; + } + + .email-edit-subject { + font-weight: bold; + font-size: 1rem; + } + + .email-edit-body { + min-height: 150px; + resize: vertical; + line-height: 1.6; + } + + .email-edit-actions { + display: flex; + gap: 0.5rem; + margin-top: 1rem; + } + + .preview-mode .email-edit-subject, + .preview-mode .email-edit-body, + .preview-mode .email-edit-actions { + display: none; + } + + .edit-mode .email-subject, + .edit-mode .email-body { + display: none; + } + .representatives-grid { display: grid; gap: 1rem; @@ -249,7 +286,7 @@
Enter Your Info
Find Representatives
-
Send Emails
+
Send Messages
@@ -281,11 +318,22 @@ - + + \ No newline at end of file diff --git a/influence/app/public/css/styles.css b/influence/app/public/css/styles.css index ea53c96..decce29 100644 --- a/influence/app/public/css/styles.css +++ b/influence/app/public/css/styles.css @@ -370,6 +370,8 @@ header p { display: flex; align-items: center; justify-content: center; + padding: 20px; + box-sizing: border-box; } .modal-content { @@ -434,6 +436,175 @@ header p { margin-top: 5px; } +/* Email Preview Modal Styles */ +.preview-modal { + max-width: 800px; + max-height: 95vh; +} + +.preview-modal .modal-body { + max-height: calc(95vh - 120px); + overflow-y: auto; +} + +.preview-section { + margin-bottom: 24px; +} + +.preview-section h4 { + color: #005a9c; + margin-bottom: 12px; + font-size: 1.1em; + font-weight: 600; +} + +.email-details { + background-color: #f8f9fa; + padding: 16px; + border-radius: 6px; + border: 1px solid #e9ecef; +} + +.detail-row { + margin-bottom: 8px; + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.detail-row:last-child { + margin-bottom: 0; +} + +.detail-row strong { + color: #495057; + min-width: 60px; + flex-shrink: 0; +} + +.detail-row span { + color: #333; + word-break: break-word; +} + +.email-preview-content { + background-color: white; + border: 1px solid #ddd; + border-radius: 6px; + padding: 20px; + max-height: 400px; + overflow-y: auto; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + line-height: 1.6; +} + +.email-preview-content p { + margin-bottom: 12px; +} + +.email-preview-content p:last-child { + margin-bottom: 0; +} + +.modal-actions { + display: flex; + gap: 12px; + justify-content: flex-end; + align-items: center; + padding-top: 20px; + border-top: 1px solid #e9ecef; + margin-top: 24px; +} + +.email-details { + background-color: #f8f9fa; + padding: 16px; + border-radius: 6px; + border: 1px solid #e9ecef; +} + +.detail-row { + margin-bottom: 8px; + display: flex; + flex-wrap: wrap; +} + +.detail-row:last-child { + margin-bottom: 0; +} + +.detail-row strong { + min-width: 60px; + color: #495057; + font-weight: 600; +} + +.detail-row span { + color: #212529; + word-break: break-word; +} + +.email-preview-content { + background-color: #ffffff; + border-radius: 6px; + min-height: 200px; + max-height: 600px; + overflow: hidden; + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + line-height: 1.6; +} + +/* Style for iframe email previews */ +.email-preview-content iframe { + display: block; + width: 100%; + border: 1px solid #dee2e6; + border-radius: 6px; + background-color: #ffffff; +} + +/* Style for text email previews */ +.email-preview-content pre { + margin: 0; + padding: 20px; + background-color: #f8f9fa; + border-radius: 6px; + border: 1px solid #dee2e6; + white-space: pre-wrap; + font-family: inherit; + overflow-x: auto; +} + +.email-preview-content img { + max-width: 100%; + height: auto; +} + +.email-preview-content a { + color: #007bff; + text-decoration: underline; +} + +.modal-actions { + display: flex; + gap: 12px; + justify-content: flex-end; + margin-top: 24px; + padding-top: 16px; + border-top: 1px solid #e9ecef; +} + +.btn.btn-outline { + background-color: transparent; + border: 1px solid #6c757d; + color: #6c757d; +} + +.btn.btn-outline:hover { + background-color: #6c757d; + color: white; +} + /* Message Display */ .message-display { position: fixed; @@ -476,6 +647,23 @@ footer a:hover { text-decoration: underline; } +.footer-actions { + margin-top: 20px; + padding-top: 20px; + border-top: 1px solid #eee; +} + +.footer-actions .btn { + font-size: 12px; + padding: 6px 12px; + opacity: 0.8; + transition: opacity 0.2s ease; +} + +.footer-actions .btn:hover { + opacity: 1; +} + /* Responsive Design */ @media (max-width: 768px) { .container { @@ -515,21 +703,365 @@ footer a:hover { flex-direction: column; align-items: center; text-align: center; - gap: 15px; } .rep-photo { - width: 100px; - height: 100px; + margin-bottom: 15px; } .rep-card .rep-actions { flex-direction: column; + align-items: center; + justify-content: center; width: 100%; } - .btn { + .rep-card .rep-actions .btn { + font-size: 14px; + padding: 12px 20px; width: 100%; + margin: 4px 0; + text-align: center; + justify-content: center; + display: flex; + align-items: center; + } +} + +/* Map Styles */ +.map-header { + text-align: center; + margin-bottom: 20px; +} + +.map-header h2 { + color: #005a9c; + margin: 0; +} + +.postal-input-section { + background: white; + padding: 20px; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + margin-bottom: 20px; +} + +.postal-input-section .form-group { + margin-bottom: 0; +} + +.postal-input-section .input-group { + display: flex; + gap: 10px; + flex-wrap: wrap; + align-items: center; +} + +.postal-input-section .input-group input { + flex: 1; + min-width: 200px; +} + +.map-container { + background: white; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + overflow: hidden; + margin-bottom: 20px; + position: relative; +} + +#main-map { + height: 400px; + width: 100%; + z-index: 1; +} + +/* Map message overlay */ +.map-message { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + z-index: 1000; + background: rgba(0, 0, 0, 0.8); + color: white; + padding: 15px 25px; + border-radius: 8px; + font-size: 14px; + text-align: center; + max-width: 300px; +} + +/* Office marker styles */ +.office-marker { + background: none; + border: none; +} + +.office-marker .marker-content { + background: white; + border: 3px solid #005a9c; + border-radius: 50%; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); + transition: all 0.2s ease; +} + +.office-marker.federal .marker-content { + border-color: #d32f2f; + background: #ffebee; +} + +.office-marker.provincial .marker-content { + border-color: #1976d2; + background: #e3f2fd; +} + +.office-marker.municipal .marker-content { + border-color: #388e3c; + background: #e8f5e8; +} + +.office-marker:hover .marker-content { + transform: scale(1.1); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +/* Office popup styles */ +.leaflet-popup-content-wrapper { + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); +} + +.office-popup-content { + font-family: inherit; + max-width: 280px; +} + +.office-popup-content .rep-header { + display: flex; + align-items: center; + gap: 12px; + padding-bottom: 12px; + border-bottom: 2px solid #eee; + margin-bottom: 12px; +} + +.office-popup-content .rep-header.federal { + border-bottom-color: #d32f2f; +} + +.office-popup-content .rep-header.provincial { + border-bottom-color: #1976d2; +} + +.office-popup-content .rep-header.municipal { + border-bottom-color: #388e3c; +} + +.office-popup-content .rep-photo-small { + width: 50px; + height: 50px; + border-radius: 50%; + object-fit: cover; + border: 2px solid #ddd; +} + +.office-popup-content .rep-info h4 { + margin: 0 0 4px 0; + color: #333; + font-size: 16px; + font-weight: 600; +} + +.office-popup-content .rep-level { + margin: 0 0 2px 0; + font-size: 12px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.office-popup-content .rep-header.federal .rep-level { + color: #d32f2f; +} + +.office-popup-content .rep-header.provincial .rep-level { + color: #1976d2; +} + +.office-popup-content .rep-header.municipal .rep-level { + color: #388e3c; +} + +.office-popup-content .rep-district { + margin: 0; + font-size: 13px; + color: #666; +} + +.office-popup-content .office-details h5 { + margin: 0 0 8px 0; + color: #333; + font-size: 14px; + font-weight: 600; +} + +.office-popup-content .office-details p { + margin: 0 0 6px 0; + font-size: 13px; + line-height: 1.4; +} + +.office-popup-content .office-details p:last-child { + margin-bottom: 0; +} + +.office-popup-content .office-details a { + color: #005a9c; + text-decoration: none; +} + +.office-popup-content .office-details a:hover { + text-decoration: underline; +} + +.office-popup-content .office-actions { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid #eee; +} + +.office-popup-content .btn-small { + padding: 6px 12px; + font-size: 12px; + font-weight: 500; +} + +.office-popup-content .shared-location-note { + margin-top: 4px; +} + +.office-popup-content .shared-location-note small { + color: #666; + font-style: italic; +} + +/* Visit office buttons */ +.visit-office { + display: inline-flex; + flex-direction: column; + align-items: center; + padding: 8px 12px; + margin: 3px; + font-size: 13px; + line-height: 1.3; + min-width: 120px; + max-width: 180px; + width: 180px; + text-align: center; + border: 1px solid #005a9c; + border-radius: 6px; + background: white; + color: #005a9c; + transition: all 0.2s ease; + word-wrap: break-word; + overflow-wrap: break-word; + hyphens: auto; + white-space: normal; +} + +.visit-office:hover { + background: #005a9c; + color: white; + transform: translateY(-1px); + box-shadow: 0 2px 6px rgba(0, 90, 156, 0.2); +} + +.visit-office .office-location { + display: block; + margin-top: 2px; + font-size: 11px; + opacity: 0.8; + font-weight: normal; + word-wrap: break-word; + overflow-wrap: break-word; + hyphens: auto; + line-height: 1.2; + white-space: normal; + width: 100%; + max-width: 100%; + overflow: hidden; +} + +.btn-sm { + padding: 6px 12px; + font-size: 12px; + line-height: 1.4; +} + +/* Responsive map styles */ +@media (max-width: 768px) { + .map-header { + flex-direction: column; + align-items: stretch; text-align: center; } + + .map-controls { + justify-content: center; + } + + #main-map { + height: 300px; + } + + .office-popup-content { + max-width: 250px; + } + + .office-popup-content .rep-header { + flex-direction: column; + text-align: center; + gap: 8px; + } +} + +@media (max-width: 480px) { + #main-map { + height: 250px; + } + + .map-controls { + flex-direction: column; + } + + .office-popup-content { + max-width: 220px; + } + + .visit-office { + max-width: calc(100vw - 40px); + width: auto; + min-width: 140px; + text-align: center; + justify-content: center; + align-items: center; + flex-shrink: 1; + } + + .visit-office .office-location { + text-align: center; + width: 100%; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + } } \ No newline at end of file diff --git a/influence/app/public/email-test.html b/influence/app/public/email-test.html index e902962..c52cc68 100644 --- a/influence/app/public/email-test.html +++ b/influence/app/public/email-test.html @@ -3,7 +3,7 @@ - Email Testing Interface - Alberta Influence Campaign + Email Testing Interface - BNKops Influence Campaign + + +
+ ← Back + +
+

Terms of Use & Privacy Notice

+

BNKops Influence Campaign Tool

+
+ +
+
+ Important Notice: By using this application, you acknowledge that your interactions are recorded and you may receive communications from BNKops, the operator of this website. This service is provided to facilitate democratic engagement between Canadian residents and their elected representatives. +
+ +

1. Acceptance of Terms

+

By accessing and using the BNKops Influence Campaign Tool (the "Service"), you accept and agree to be bound by the terms and provision of this agreement. This Service is operated by BNKops ("we," "us," or "our") and is intended for use by Canadian residents to facilitate communication with their elected representatives.

+ +

2. Description of Service

+

The BNKops Influence Campaign Tool is a web-based platform that enables users to:

+
    +
  • Find their elected representatives at federal, provincial, and municipal levels
  • +
  • Access contact information for these representatives
  • +
  • Compose and send emails to their representatives
  • +
  • Participate in organized advocacy campaigns
  • +
  • View office locations and contact details
  • +
+ +

3. Data Collection and Privacy

+

3.1 Information We Collect

+

We collect and store the following information:

+
    +
  • Contact Information: Name, email address, and postal code when you use our email services
  • +
  • Communication Content: Email messages you compose and send through our platform
  • +
  • Usage Data: Information about how you interact with our service, including pages visited, features used, and timestamps
  • +
  • Technical Data: IP address, browser type, device information, and other technical identifiers
  • +
  • Representative Data: Information about elected officials obtained from public APIs and sources
  • +
+ +

3.2 How We Use Your Information

+

Your information is used to:

+
    +
  • Facilitate communication between you and your elected representatives
  • +
  • Maintain records of campaign participation and email sending activities
  • +
  • Improve our service and user experience
  • +
  • Comply with legal obligations and respond to legal requests
  • +
  • Send you communications about campaigns, service updates, or related democratic engagement opportunities
  • +
+ +

3.3 Data Retention

+

We retain your personal information for as long as necessary to provide our services and comply with legal obligations. Communication records may be retained indefinitely for transparency and accountability purposes.

+ +

4. Communications from BNKops

+
+ Notice: By using this service, you consent to receiving communications from BNKops regarding: +
    +
  • Service updates and improvements
  • +
  • New campaign opportunities
  • +
  • Democratic engagement initiatives
  • +
  • Technical notifications and security updates
  • +
+

You may opt out of non-essential communications by contacting us using the information provided below.

+
+ +

5. User Responsibilities

+

As a user of this service, you agree to:

+
    +
  • Provide accurate and truthful information
  • +
  • Use the service only for legitimate democratic engagement purposes
  • +
  • Respect the time and resources of elected representatives
  • +
  • Not use the service for spam, harassment, or illegal activities
  • +
  • Not attempt to compromise the security or functionality of the service
  • +
  • Comply with all applicable Canadian federal, provincial, and municipal laws
  • +
+ +

6. Prohibited Uses

+

You may not use this service to:

+
    +
  • Send threatening, abusive, or harassing communications
  • +
  • Distribute spam or unsolicited commercial content
  • +
  • Impersonate another person or provide false identity information
  • +
  • Attempt to gain unauthorized access to other users' data
  • +
  • Use automated tools to send bulk communications without authorization
  • +
  • Violate any applicable laws or regulations
  • +
+ +

7. Third-Party Services

+

Our service integrates with third-party services including:

+
    +
  • Represent API (Open North): For obtaining representative information
  • +
  • Email Service Providers: For sending communications
  • +
  • Database Services: For data storage and management
  • +
+

These third parties have their own privacy policies and terms of service, which govern their collection and use of your information.

+ +

8. Limitation of Liability

+

BNKops provides this service "as is" without warranties of any kind. We are not responsible for:

+
    +
  • The accuracy or completeness of representative contact information
  • +
  • The delivery or response to communications sent through our platform
  • +
  • Any actions taken by elected representatives based on communications sent
  • +
  • Service interruptions or technical issues
  • +
  • Any damages resulting from your use of the service
  • +
+ +

9. Privacy Rights (Canadian Law)

+

Under Canadian privacy law, you have the right to:

+
    +
  • Access your personal information held by us
  • +
  • Request correction of inaccurate information
  • +
  • Request deletion of your personal information (subject to legal retention requirements)
  • +
  • Withdraw consent for certain uses of your information
  • +
  • File a complaint with the Privacy Commissioner of Canada
  • +
+ +

10. Changes to Terms

+

We reserve the right to modify these terms at any time. Changes will be posted on this page with an updated revision date. Continued use of the service after changes constitutes acceptance of the new terms.

+ +

11. Governing Law

+

These terms are governed by the laws of Canada and the province in which BNKops operates. Any disputes will be resolved in the appropriate Canadian courts.

+ +

12. Contact Information

+
+ BNKops
+ For questions about these terms, privacy concerns, or to exercise your rights:
+ Website: https://bnkops.com
+ Email: privacy@bnkops.com
+
+ For technical support or service-related inquiries, please contact us through our website. +
+ +

13. Severability

+

If any provision of these terms is found to be unenforceable, the remaining provisions will continue in full force and effect.

+ +
+ Last updated: September 23, 2025 +
+
+ + +
+ + \ No newline at end of file diff --git a/influence/app/routes/api.js b/influence/app/routes/api.js index 44a8952..cd6d74b 100644 --- a/influence/app/routes/api.js +++ b/influence/app/routes/api.js @@ -41,6 +41,21 @@ router.post( ); // Email endpoints +router.post( + '/emails/preview', + rateLimiter.general, + [ + body('recipientEmail').isEmail().withMessage('Valid recipient email is required'), + body('senderName').notEmpty().withMessage('Sender name is required'), + body('senderEmail').isEmail().withMessage('Valid sender email is required'), + body('subject').notEmpty().withMessage('Subject is required'), + body('message').notEmpty().withMessage('Message is required'), + body('postalCode').matches(/^[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d$/).withMessage('Invalid postal code format') + ], + handleValidationErrors, + emailsController.previewEmail +); + router.post( '/emails/send', rateLimiter.email, // General hourly rate limit @@ -58,19 +73,6 @@ router.post( ); // Email testing endpoints -router.post( - '/emails/preview', - requireAdmin, - rateLimiter.general, - [ - body('recipientEmail').isEmail().withMessage('Valid recipient email is required'), - body('subject').notEmpty().withMessage('Subject is required'), - body('message').notEmpty().withMessage('Message is required') - ], - handleValidationErrors, - emailsController.previewEmail -); - router.post( '/emails/test', requireAdmin, diff --git a/influence/app/server.js b/influence/app/server.js index ff5c6a6..26c0517 100644 --- a/influence/app/server.js +++ b/influence/app/server.js @@ -12,15 +12,19 @@ const { requireAdmin } = require('./middleware/auth'); const app = express(); const PORT = process.env.PORT || 3333; +// Trust proxy for Docker/reverse proxy environments +// Only trust Docker internal networks for better security +app.set('trust proxy', ['127.0.0.1', '::1', '172.16.0.0/12', '192.168.0.0/16', '10.0.0.0/8']); + // Security middleware app.use(helmet({ contentSecurityPolicy: { directives: { defaultSrc: ["'self'"], - styleSrc: ["'self'", "'unsafe-inline'"], - scriptSrc: ["'self'"], + styleSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com"], + scriptSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com", "https://static.cloudflareinsights.com"], imgSrc: ["'self'", "data:", "https:"], - connectSrc: ["'self'"], + connectSrc: ["'self'", "https://cloudflareinsights.com"], }, }, })); diff --git a/influence/app/services/email.js b/influence/app/services/email.js index c05f494..c768f8d 100644 --- a/influence/app/services/email.js +++ b/influence/app/services/email.js @@ -261,12 +261,16 @@ class EmailService { } } - async sendRepresentativeEmail(recipientEmail, senderName, senderEmail, subject, message, postalCode) { + async sendRepresentativeEmail(recipientEmail, senderName, senderEmail, subject, message, postalCode, recipientName = null) { + // Generate dynamic subject if not provided + const finalSubject = subject || `Message from ${senderName} from ${postalCode}`; + const templateVariables = { MESSAGE: message, SENDER_NAME: senderName, SENDER_EMAIL: senderEmail, - POSTAL_CODE: postalCode + POSTAL_CODE: postalCode, + RECIPIENT_NAME: recipientName || 'Representative' }; const emailOptions = { @@ -276,7 +280,7 @@ class EmailService { name: process.env.SMTP_FROM_NAME }, replyTo: senderEmail, - subject: subject + subject: finalSubject }; return await this.sendTemplatedEmail('representative-contact', templateVariables, emailOptions); diff --git a/influence/app/services/emailTemplates.js b/influence/app/services/emailTemplates.js index 81c7b8f..6dbd342 100644 --- a/influence/app/services/emailTemplates.js +++ b/influence/app/services/emailTemplates.js @@ -70,7 +70,7 @@ class EmailTemplateService { // Add default variables const defaultVariables = { - APP_NAME: process.env.APP_NAME || 'BNKops Influence Tool', + APP_NAME: process.env.APP_NAME || 'BNKops Influence Campaign', TIMESTAMP: new Date().toLocaleString(), ...variables }; diff --git a/influence/app/services/nocodb.js b/influence/app/services/nocodb.js index 22c6fe1..0fe4cbf 100644 --- a/influence/app/services/nocodb.js +++ b/influence/app/services/nocodb.js @@ -80,19 +80,35 @@ class NocoDBService { // Create record async create(tableId, data) { try { - // Clean the data to remove any null values which can cause NocoDB issues + // Clean the data to remove any null values and system fields that NocoDB manages const cleanData = Object.keys(data).reduce((clean, key) => { - if (data[key] !== null && data[key] !== undefined) { - clean[key] = data[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:', 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; } } @@ -122,7 +138,24 @@ class NocoDBService { 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, @@ -134,23 +167,33 @@ class NocoDBService { '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() }; - const result = await this.create(this.tableIds.representatives, record); - stored.push(result); + 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) { - // 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 }; + } 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 }; } } @@ -158,10 +201,25 @@ class NocoDBService { try { // Try to query with the most likely column name const response = await this.getAll(this.tableIds.representatives, { - where: `(postal_code,eq,${postalCode})` + where: `(Postal Code,eq,${postalCode})` }); - return response.list || []; + 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)) { @@ -200,6 +258,7 @@ class NocoDBService { '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, @@ -214,6 +273,41 @@ class NocoDBService { } } + 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 { @@ -239,7 +333,7 @@ class NocoDBService { const conditions = []; if (filters.postalCode) { - conditions.push(`(postal_code,eq,${filters.postalCode})`); + conditions.push(`(Postal Code,eq,${filters.postalCode})`); } if (filters.senderEmail) { conditions.push(`(sender_email,eq,${filters.senderEmail})`); @@ -335,6 +429,7 @@ class NocoDBService { '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 }; @@ -361,7 +456,8 @@ class NocoDBService { 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.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; @@ -406,11 +502,26 @@ class NocoDBService { // 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; + 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); - throw 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 }; } } diff --git a/influence/app/templates/email/representative-contact.html b/influence/app/templates/email/representative-contact.html index 1cf6047..61938c0 100644 --- a/influence/app/templates/email/representative-contact.html +++ b/influence/app/templates/email/representative-contact.html @@ -78,15 +78,15 @@ -
-
- -

Constituent Communication Platform

-
-
+

Dear {{RECIPIENT_NAME}},

+ {{MESSAGE}} + +

Sincerely,
+ {{SENDER_NAME}}
+ {{POSTAL_CODE}}

diff --git a/influence/app/templates/email/representative-contact.txt b/influence/app/templates/email/representative-contact.txt index ae054aa..c798d0c 100644 --- a/influence/app/templates/email/representative-contact.txt +++ b/influence/app/templates/email/representative-contact.txt @@ -1,7 +1,11 @@ -Message from Constituent - {{APP_NAME}} +Dear {{RECIPIENT_NAME}}, {{MESSAGE}} +Sincerely, +{{SENDER_NAME}} +{{POSTAL_CODE}} + --- Constituent Information: Name: {{SENDER_NAME}} diff --git a/influence/app/utils/rate-limiter.js b/influence/app/utils/rate-limiter.js index 2938827..5ea7564 100644 --- a/influence/app/utils/rate-limiter.js +++ b/influence/app/utils/rate-limiter.js @@ -26,6 +26,11 @@ const general = rateLimit({ }, standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers legacyHeaders: false, // Disable the `X-RateLimit-*` headers + // Use a custom key generator that's safer with trust proxy + keyGenerator: (req) => { + // Fallback to connection remote address if req.ip is not available + return req.ip || req.connection?.remoteAddress || 'unknown'; + }, }); // Email sending rate limiter (general - keeps existing behavior) @@ -39,11 +44,16 @@ const email = rateLimit({ standardHeaders: true, legacyHeaders: false, skipSuccessfulRequests: false, // Don't skip counting successful requests + // Use a custom key generator that's safer with trust proxy + keyGenerator: (req) => { + // Fallback to connection remote address if req.ip is not available + return req.ip || req.connection?.remoteAddress || 'unknown'; + }, }); // Custom middleware for per-recipient email rate limiting const perRecipientEmailLimiter = (req, res, next) => { - const clientIp = req.ip || req.connection.remoteAddress; + const clientIp = req.ip || req.connection?.remoteAddress || 'unknown'; const recipientEmail = req.body.recipientEmail; if (!recipientEmail) { @@ -80,6 +90,11 @@ const representAPI = rateLimit({ }, standardHeaders: true, legacyHeaders: false, + // Use a custom key generator that's safer with trust proxy + keyGenerator: (req) => { + // Fallback to connection remote address if req.ip is not available + return req.ip || req.connection?.remoteAddress || 'unknown'; + }, }); module.exports = { diff --git a/influence/example.env b/influence/example.env index 6b71105..75d0369 100644 --- a/influence/example.env +++ b/influence/example.env @@ -1,4 +1,4 @@ -# Alberta Influence Campaign Tool - Environment Configuration Example +# BNKops Influence Campaign Tool - Environment Configuration Example # Copy this file to .env and update with your actual values # NocoDB Configuration @@ -57,7 +57,7 @@ NOCODB_TABLE_USERS= # SMTP_USER= # SMTP_PASS= # SMTP_FROM_EMAIL=dev@albertainfluence.local -# SMTP_FROM_NAME="Alberta Influence Campaign (DEV)" +# SMTP_FROM_NAME="BNKops Influence Campaign (DEV)" # Security Notes: # - Keep your .env file secure and never commit it to version control diff --git a/influence/files-explainer.md b/influence/files-explainer.md index 8b2f458..d663131 100644 --- a/influence/files-explainer.md +++ b/influence/files-explainer.md @@ -1,6 +1,6 @@ -# Alberta Influence Campaign Tool - File Structure Explanation +# BNKops Influence Campaign Tool - File Structure Explanation -This document explains the purpose and functionality of each file in the Alberta Influence Campaign Tool. +This document explains the purpose and functionality of each file in the BNKops Influence Campaign Tool. ## Authentication System diff --git a/influence/influence-campaign-setup.md b/influence/influence-campaign-setup.md index 3fadfda..054c311 100644 --- a/influence/influence-campaign-setup.md +++ b/influence/influence-campaign-setup.md @@ -1,4 +1,4 @@ -# Alberta Influence Campaign Tool - Complete Setup Guide +# BNKops Influence Campaign Tool - Complete Setup Guide ## Project Overview A locally-hosted political influence campaign tool for Alberta constituents to contact their representatives via email. Uses the Represent OpenNorth API for representative data and provides both self-service and campaign modes. @@ -90,7 +90,7 @@ SMTP_PORT=587 SMTP_USER=apikey SMTP_PASS=your_smtp_password_here SMTP_FROM_EMAIL=noreply@yourcampaign.ca -SMTP_FROM_NAME=Alberta Influence Campaign +SMTP_FROM_NAME=BNKops Influence Campaign # Admin Configuration ADMIN_PASSWORD=secure_admin_password_here @@ -308,7 +308,7 @@ class RepresentAPIService { const response = await axios.get(`${this.baseURL}/postcodes/${formattedPostal}/`, { timeout: 10000, headers: { - 'User-Agent': 'Alberta Influence Campaign Tool' + 'User-Agent': 'BNKops Influence Campaign Tool' } }); @@ -582,7 +582,7 @@ class EmailService { replyTo: from_email, headers: { 'X-Sender-Email': from_email, - 'X-Campaign': 'Alberta Influence Campaign' + 'X-Campaign': 'BNKops Influence Campaign' } }; @@ -621,7 +621,7 @@ class EmailService { ${message.split('\n').map(para => `

${para}

`).join('')}
diff --git a/influence/instruct.md b/influence/instruct.md index 802479f..87502db 100644 --- a/influence/instruct.md +++ b/influence/instruct.md @@ -1,6 +1,6 @@ # Instructions -Welcome to the BNKops Influence project! Welcome to BNNKops Influence, a tool for creating political change by targeting influential individuals within a community. This application is designed to help campaigns identify and engage with key figures who can sway public opinion and mobilize support. +Welcome to BNKops Influence, a tool for creating political change by targeting influential individuals within a community. This application is designed to help campaigns identify and engage with key figures who can sway public opinion and mobilize support. ## Environment Setup diff --git a/influence/scripts/build-nocodb.sh b/influence/scripts/build-nocodb.sh index 23a6e60..bc689c0 100755 --- a/influence/scripts/build-nocodb.sh +++ b/influence/scripts/build-nocodb.sh @@ -1,11 +1,22 @@ #!/bin/bash -# NocoDB Auto-Setup Script for Alberta Influence Campaign Tool +# NocoDB Auto-Setup Script for BNKops Influence Campaign Tool # Based on the successful map setup script # This script creates tables in your existing NocoDB project +# +# Updated: September 2025 - Added data migration option from existing NocoDB bases +# Usage: +# ./build-nocodb.sh # Create new base only +# ./build-nocodb.sh --migrate-data # Create new base with data migration option +# ./build-nocodb.sh --help # Show usage information set -e # Exit on any error +# Global variables for migration +MIGRATE_DATA=true +SOURCE_BASE_ID="" +SOURCE_TABLE_IDS="" + # Change to the influence root directory (parent of scripts directory) SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" INFLUENCE_ROOT="$(dirname "$SCRIPT_DIR")" @@ -37,6 +48,89 @@ print_error() { echo -e "${RED}[ERROR]${NC} $1" >&2 } +# Function to show usage information +show_usage() { + cat << EOF +NocoDB Auto-Setup Script for BNKops Influence Campaign Tool + +USAGE: + $0 [OPTIONS] + +OPTIONS: + --migrate-data Skip interactive prompt and enable data migration mode + --help Show this help message + +DESCRIPTION: + This script creates a new NocoDB base with the required tables for the Influence Campaign Tool. + + Interactive mode (default): Prompts you to choose between fresh installation or data migration. + + With --migrate-data option, skips the prompt and goes directly to migration setup, allowing + you to select an existing base and migrate data from specific tables to the new base. + +EXAMPLES: + $0 # Interactive mode - choose fresh or migration + $0 --migrate-data # Skip prompt, go directly to migration setup + $0 --help # Show this help + +MIGRATION FEATURES: + - Automatically detects current base from .env file settings + - Interactive base and table selection with clear guidance + - Filters out auto-generated columns (CreatedAt, UpdatedAt, etc.) + - Preserves original data (creates new base, doesn't modify existing) + - Progress tracking during import with detailed success/failure reporting + +EOF +} + +# Parse command line arguments +parse_arguments() { + while [[ $# -gt 0 ]]; do + case $1 in + --migrate-data) + MIGRATE_DATA=true + shift + ;; + --help|-h) + show_usage + exit 0 + ;; + *) + print_error "Unknown option: $1" + show_usage + exit 1 + ;; + esac + done +} + +# Check for required dependencies +check_dependencies() { + local missing_deps=() + + # Check for jq (required for JSON parsing in migration) + if ! command -v jq &> /dev/null; then + missing_deps+=("jq") + fi + + # Check for curl (should be available but let's verify) + if ! command -v curl &> /dev/null; then + missing_deps+=("curl") + fi + + if [[ ${#missing_deps[@]} -gt 0 ]]; then + print_error "Missing required dependencies: ${missing_deps[*]}" + print_error "Please install the missing dependencies before running this script" + print_status "On Ubuntu/Debian: sudo apt-get install ${missing_deps[*]}" + print_status "On CentOS/RHEL: sudo yum install ${missing_deps[*]}" + print_status "On macOS: brew install ${missing_deps[*]}" + exit 1 + fi +} + +# Check dependencies +check_dependencies + # Load environment variables if [ -f ".env" ]; then set -a @@ -158,6 +252,440 @@ test_api_connectivity() { fi } +# Function to list all available bases +list_available_bases() { + print_status "Fetching available NocoDB bases..." + + local response + response=$(make_api_call "GET" "/meta/bases" "" "Fetching bases list" "v2") + + if [[ $? -eq 0 && -n "$response" ]]; then + echo "$response" + return 0 + else + print_error "Failed to fetch bases list" + return 1 + fi +} + +# Function to list tables in a specific base +list_base_tables() { + local base_id=$1 + + print_status "Fetching tables for base: $base_id" + + local response + response=$(make_api_call "GET" "/meta/bases/$base_id/tables" "" "Fetching tables list" "v2") + + if [[ $? -eq 0 && -n "$response" ]]; then + echo "$response" + return 0 + else + print_error "Failed to fetch tables list" + return 1 + fi +} + +# Function to export data from a table +export_table_data() { + local base_id=$1 + local table_id=$2 + local table_name=$3 + + print_status "Exporting data from table: $table_name (ID: $table_id)" + + # First, get total count of records using a minimal request + local count_response + count_response=$(make_api_call "GET" "/tables/$table_id/records?limit=1" "" "Getting record count for $table_name" "v2") + + if [[ $? -ne 0 ]]; then + print_warning "Failed to get record count for table: $table_name" + return 1 + fi + + # Extract total count from pageInfo + local total_count + total_count=$(echo "$count_response" | jq -r '.pageInfo.totalRows // 0' 2>/dev/null) + + if [[ -z "$total_count" || "$total_count" == "null" || "$total_count" -eq 0 ]]; then + print_warning "No records found in table: $table_name" + echo '{"list": [], "pageInfo": {"totalRows": 0}}' + return 0 + fi + + print_status "Found $total_count records in table: $table_name" + + # If we have a small number of records, get them all at once + if [[ "$total_count" -le 100 ]]; then + local data_response + data_response=$(make_api_call "GET" "/tables/$table_id/records?limit=$total_count" "" "Exporting all records from $table_name" "v2") + + if [[ $? -eq 0 ]]; then + echo "$data_response" + return 0 + else + print_error "Failed to export data from table: $table_name" + return 1 + fi + else + # For larger datasets, implement pagination + local all_records="[]" + local offset=0 + local limit=100 + + while [[ $offset -lt $total_count ]]; do + print_status "Fetching records $((offset+1))-$((offset+limit)) of $total_count from $table_name" + + local batch_response + batch_response=$(make_api_call "GET" "/tables/$table_id/records?limit=$limit&offset=$offset" "" "Fetching batch from $table_name" "v2") + + if [[ $? -eq 0 ]]; then + local batch_records + batch_records=$(echo "$batch_response" | jq -r '.list' 2>/dev/null) + + if [[ -n "$batch_records" && "$batch_records" != "null" ]]; then + all_records=$(echo "$all_records" | jq ". + $batch_records" 2>/dev/null) + fi + else + print_warning "Failed to fetch batch from table: $table_name (offset: $offset)" + fi + + offset=$((offset + limit)) + done + + # Return the compiled results + echo "{\"list\": $all_records, \"pageInfo\": {\"totalRows\": $total_count}}" + return 0 + fi +} + +# Function to import data into a table +import_table_data() { + local base_id=$1 + local table_id=$2 + local table_name=$3 + local data=$4 + + # Check if data contains records + local record_count=$(echo "$data" | grep -o '"list":\[' | wc -l) + + if [[ $record_count -eq 0 ]]; then + print_warning "No data to import for table: $table_name" + return 0 + fi + + # Extract the records array from the response + local records_array + records_array=$(echo "$data" | jq -r '.list' 2>/dev/null) + + if [[ -z "$records_array" || "$records_array" == "[]" || "$records_array" == "null" ]]; then + print_warning "No valid records found in data for table: $table_name" + return 0 + fi + + print_status "Importing data into table: $table_name (ID: $table_id)" + + # Count total records first + local total_records + total_records=$(echo "$records_array" | jq 'length' 2>/dev/null) + print_status "Found $total_records records to import into $table_name" + + local import_count=0 + local success_count=0 + + # Create temporary file to track results across subshell + local temp_file="/tmp/nocodb_import_$$" + echo "0" > "$temp_file" + + # Add progress reporting for large datasets + local progress_interval=25 + if [[ $total_records -gt 200 ]]; then + progress_interval=50 + fi + if [[ $total_records -gt 1000 ]]; then + progress_interval=100 + fi + + # Parse records and import them one by one (to handle potential ID conflicts) + echo "$records_array" | jq -c '.[]' 2>/dev/null | while read -r record; do + import_count=$((import_count + 1)) + + # Show progress for large datasets + if [[ $((import_count % progress_interval)) -eq 0 ]]; then + print_status "Progress: $import_count/$total_records records processed for $table_name" + fi + + # Remove auto-generated fields that might cause conflicts + local cleaned_record + cleaned_record=$(echo "$record" | jq 'del(.Id, .CreatedAt, .UpdatedAt, .id, .created_at, .updated_at)' 2>/dev/null) + + if [[ -z "$cleaned_record" || "$cleaned_record" == "null" ]]; then + print_warning "Skipping invalid record $import_count in $table_name" + continue + fi + + # Import the record + local import_response + import_response=$(make_api_call "POST" "/tables/$table_id/records" "$cleaned_record" "Importing record $import_count to $table_name" "v2" 2>/dev/null) + + if [[ $? -eq 0 ]]; then + local current_success=$(cat "$temp_file" 2>/dev/null || echo "0") + echo $((current_success + 1)) > "$temp_file" + else + print_warning "Failed to import record $import_count to $table_name" + fi + done + + # Read final success count + local final_success_count=$(cat "$temp_file" 2>/dev/null || echo "0") + rm -f "$temp_file" + + print_success "Data import completed for table: $table_name ($final_success_count/$total_records records imported)" +} + +# Function to prompt user for base selection +select_source_base() { + print_status "Fetching available bases for migration..." + + local bases_response + bases_response=$(list_available_bases) + + if [[ $? -ne 0 ]]; then + print_error "Failed to fetch available bases" + return 1 + fi + + # Debug the response structure + print_status "Raw response preview: ${bases_response:0:200}..." + + # Parse bases from the .list array - use direct approach since we know the structure + local bases_info="" + bases_info=$(echo "$bases_response" | jq -r '.list[] | "\(.id)|\(.title)|\(.description // "No description")"' 2>&1) + + # Check if jq failed + local jq_exit_code=$? + if [[ $jq_exit_code -ne 0 ]]; then + print_error "jq parsing failed with exit code: $jq_exit_code" + print_error "jq error output: $bases_info" + return 1 + fi + + if [[ -z "$bases_info" ]]; then + print_error "No bases found or parsing returned empty result" + print_error "Response structure: $(echo "$bases_response" | jq -r 'keys' 2>/dev/null || echo "Invalid JSON")" + # Show a sample of the data for debugging + print_status "Sample data: $(echo "$bases_response" | jq -r '.list[0] // "No data found"' 2>/dev/null)" + return 1 + fi + + # Try to detect current base from .env file + local current_base_id="" + if [[ -n "$NOCODB_PROJECT_ID" ]]; then + current_base_id="$NOCODB_PROJECT_ID" + fi + + echo "" + print_status "Available bases for data migration:" + print_status "=====================================" + + local counter=1 + local suggested_option="" + + echo "$bases_info" | while IFS='|' read -r base_id title description; do + if [[ "$base_id" == "$current_base_id" ]]; then + echo " $counter) $title (ID: $base_id) [CURRENT] - $description" + suggested_option="$counter" + else + echo " $counter) $title (ID: $base_id) - $description" + fi + counter=$((counter + 1)) + done + + echo "" + if [[ -n "$current_base_id" ]]; then + print_warning "Detected current base ID from .env: $current_base_id" + echo -n "Enter the number of the base to migrate from (or 'skip'): " + else + echo -n "Enter the number of the base you want to migrate from (or 'skip'): " + fi + + read -r selection + + if [[ "$selection" == "skip" ]]; then + print_status "Skipping data migration" + return 1 + fi + + if ! [[ "$selection" =~ ^[0-9]+$ ]]; then + print_error "Invalid selection: $selection" + return 1 + fi + + # Get the selected base ID + local selected_base_id + selected_base_id=$(echo "$bases_info" | sed -n "${selection}p" | cut -d'|' -f1) + + if [[ -z "$selected_base_id" ]]; then + print_error "Invalid selection number: $selection" + return 1 + fi + + SOURCE_BASE_ID="$selected_base_id" + print_success "Selected base ID: $SOURCE_BASE_ID" + return 0 +} + +# Function to select tables for migration +select_migration_tables() { + local source_base_id=$1 + + print_status "Fetching tables from source base..." + + local tables_response + tables_response=$(list_base_tables "$source_base_id") + + if [[ $? -ne 0 ]]; then + print_error "Failed to fetch tables from source base" + return 1 + fi + + # Parse and display available tables + local tables_info="" + tables_info=$(echo "$tables_response" | jq -r '.list[] | "\(.id)|\(.title)|\(.table_name)"' 2>&1) + + # Check if jq failed + local jq_exit_code=$? + if [[ $jq_exit_code -ne 0 ]]; then + print_error "jq parsing failed for tables with exit code: $jq_exit_code" + print_error "jq error output: $tables_info" + return 1 + fi + + if [[ -z "$tables_info" ]]; then + print_error "No tables found or parsing returned empty result" + print_error "Tables response structure: $(echo "$tables_response" | jq -r 'keys' 2>/dev/null || echo "Invalid JSON")" + return 1 + fi + + echo "" + print_status "Available tables in source base:" + print_status "================================" + + local counter=1 + echo "$tables_info" | while IFS='|' read -r table_id title table_name; do + echo " $counter) $title (table: $table_name, ID: $table_id)" + counter=$((counter + 1)) + done + + echo "" + print_status "Select tables to migrate (comma-separated numbers, or 'all' for all tables):" + echo -n "Selection: " + read -r table_selection + + if [[ "$table_selection" == "all" ]]; then + SOURCE_TABLE_IDS=$(echo "$tables_info" | cut -d'|' -f1 | tr '\n' ',' | sed 's/,$//') + else + # Parse comma-separated numbers + local selected_ids="" + IFS=',' read -ra SELECTIONS <<< "$table_selection" + for selection in "${SELECTIONS[@]}"; do + selection=$(echo "$selection" | xargs) # trim whitespace + if [[ "$selection" =~ ^[0-9]+$ ]]; then + local table_id + table_id=$(echo "$tables_info" | sed -n "${selection}p" | cut -d'|' -f1) + if [[ -n "$table_id" ]]; then + selected_ids="$selected_ids$table_id," + fi + fi + done + SOURCE_TABLE_IDS=$(echo "$selected_ids" | sed 's/,$//') + fi + + if [[ -z "$SOURCE_TABLE_IDS" ]]; then + print_error "No valid tables selected" + return 1 + fi + + print_success "Selected table IDs: $SOURCE_TABLE_IDS" + return 0 +} + +# Function to migrate data from source to destination +migrate_table_data() { + local source_base_id=$1 + local dest_base_id=$2 + local source_table_id=$3 + local dest_table_id=$4 + local table_name=$5 + + print_status "Migrating data from $table_name..." + + # Export data from source table + local exported_data + exported_data=$(export_table_data "$source_base_id" "$source_table_id" "$table_name") + + if [[ $? -ne 0 ]]; then + print_error "Failed to export data from source table: $table_name" + return 1 + fi + + # Import data to destination table + import_table_data "$dest_base_id" "$dest_table_id" "$table_name" "$exported_data" + + if [[ $? -eq 0 ]]; then + print_success "Successfully migrated data for table: $table_name" + return 0 + else + print_error "Failed to migrate data for table: $table_name" + return 1 + fi +} + +# Function to extract base ID from URL +extract_base_id_from_url() { + local url="$1" + echo "$url" | grep -o '/nc/[^/]*' | sed 's|/nc/||' +} + +# Function to prompt user about data migration +prompt_migration_choice() { + print_status "NocoDB Auto-Setup - Migration Options" + print_status "=====================================" + echo "" + print_status "This script will create a new NocoDB base with fresh tables." + echo "" + print_status "Migration Options:" + print_status " 1) Fresh installation (create new base with default data)" + print_status " 2) Migrate from existing base (preserve your current data)" + echo "" + + # Check if we have existing project ID in .env to suggest migration + if [[ -n "$NOCODB_PROJECT_ID" ]]; then + print_warning "Detected existing project ID in .env: $NOCODB_PROJECT_ID" + print_warning "You may want to migrate data from your current base." + fi + + echo "" + echo -n "Choose option (1 or 2): " + read -r choice + + case $choice in + 1) + print_status "Selected: Fresh installation" + MIGRATE_DATA=false + ;; + 2) + print_status "Selected: Data migration" + MIGRATE_DATA=true + ;; + *) + print_error "Invalid choice: $choice" + print_error "Please choose 1 or 2" + exit 1 + ;; + esac +} + # Function to create a table create_table() { local base_id=$1 @@ -270,6 +798,12 @@ create_representatives_table() { "uidt": "URL", "rqd": false }, + { + "column_name": "offices", + "title": "Offices", + "uidt": "LongText", + "rqd": false + }, { "column_name": "cached_at", "title": "Cached At", @@ -293,10 +827,7 @@ create_email_logs_table() { { "column_name": "id", "title": "ID", - "uidt": "ID", - "pk": true, - "ai": true, - "rqd": true + "uidt": "ID" }, { "column_name": "recipient_email", @@ -322,6 +853,12 @@ create_email_logs_table() { "uidt": "SingleLineText", "rqd": false }, + { + "column_name": "message", + "title": "Message", + "uidt": "LongText", + "rqd": false + }, { "column_name": "postal_code", "title": "Postal Code", @@ -336,10 +873,17 @@ create_email_logs_table() { "colOptions": { "options": [ {"title": "sent", "color": "#00ff00"}, - {"title": "failed", "color": "#ff0000"} + {"title": "failed", "color": "#ff0000"}, + {"title": "previewed", "color": "#0080ff"} ] } }, + { + "column_name": "sender_ip", + "title": "Sender IP", + "uidt": "SingleLineText", + "rqd": false + }, { "column_name": "sent_at", "title": "Sent At", @@ -504,6 +1048,12 @@ create_campaigns_table() { "uidt": "Checkbox", "cdf": "true" }, + { + "column_name": "allow_email_editing", + "title": "Allow Email Editing", + "uidt": "Checkbox", + "cdf": "false" + }, { "column_name": "target_government_levels", "title": "Target Government Levels", @@ -700,19 +1250,19 @@ create_users_table() { # Function to create a new base create_base() { local base_data='{ - "title": "Alberta Influence Campaign Tool", + "title": "BNKops Influence Campaign Tool", "type": "database" }' local response - response=$(make_api_call "POST" "/meta/bases" "$base_data" "Creating new base: Alberta Influence Campaign Tool" "v2") + response=$(make_api_call "POST" "/meta/bases" "$base_data" "Creating new base: BNKops Influence Campaign Tool" "v2") if [[ $? -eq 0 && -n "$response" ]]; then local base_id base_id=$(echo "$response" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4) if [[ -n "$base_id" ]]; then - print_success "Base 'Alberta Influence Campaign Tool' created with ID: $base_id" + print_success "Base 'BNKops Influence Campaign Tool' created with ID: $base_id" echo "$base_id" else print_error "Failed to extract base ID from response" @@ -786,7 +1336,10 @@ update_env_with_table_ids() { # Main execution main() { - print_status "Starting NocoDB Setup for Alberta Influence Campaign Tool..." + # Parse command line arguments + parse_arguments "$@" + + print_status "Starting NocoDB Setup for BNKops Influence Campaign Tool..." print_status "============================================================" # First test API connectivity @@ -795,6 +1348,29 @@ main() { exit 1 fi + # Always prompt for migration choice unless --migrate-data was explicitly passed + if [[ "$MIGRATE_DATA" != "true" ]]; then + prompt_migration_choice + fi + + # Handle data migration setup if requested + if [[ "$MIGRATE_DATA" == "true" ]]; then + print_status "Setting up data migration..." + + if ! select_source_base; then + print_warning "Migration setup failed or skipped. Proceeding with fresh installation." + MIGRATE_DATA=false + elif ! select_migration_tables "$SOURCE_BASE_ID"; then + print_warning "Table selection failed. Proceeding with fresh installation." + MIGRATE_DATA=false + else + print_success "Migration setup completed successfully" + print_status "Will migrate data from base: $SOURCE_BASE_ID" + print_status "Selected tables: $SOURCE_TABLE_IDS" + fi + print_status "" + fi + print_status "" print_status "Creating new base for Influence Campaign Tool..." @@ -863,13 +1439,63 @@ main() { # Wait a moment for tables to be fully created sleep 3 + # Handle data migration if enabled + if [[ "$MIGRATE_DATA" == "true" && -n "$SOURCE_BASE_ID" && -n "$SOURCE_TABLE_IDS" ]]; then + print_status "Starting data migration..." + print_status "=========================" + + # Create a mapping of table names to new table IDs for migration + declare -A table_mapping + table_mapping["influence_representatives"]="$REPRESENTATIVES_TABLE_ID" + table_mapping["influence_email_logs"]="$EMAIL_LOGS_TABLE_ID" + table_mapping["influence_postal_codes"]="$POSTAL_CODES_TABLE_ID" + table_mapping["influence_campaigns"]="$CAMPAIGNS_TABLE_ID" + table_mapping["influence_campaign_emails"]="$CAMPAIGN_EMAILS_TABLE_ID" + table_mapping["influence_users"]="$USERS_TABLE_ID" + + # Get source table information + local source_tables_response + source_tables_response=$(list_base_tables "$SOURCE_BASE_ID") + + if [[ $? -eq 0 ]]; then + # Process each selected table for migration + IFS=',' read -ra TABLE_IDS <<< "$SOURCE_TABLE_IDS" + for source_table_id in "${TABLE_IDS[@]}"; do + # Get table info from source + local table_info + table_info=$(echo "$source_tables_response" | jq -r ".list[] | select(.id == \"$source_table_id\") | \"\(.table_name)|\(.title)\"" 2>/dev/null) + + if [[ -n "$table_info" ]]; then + local table_name=$(echo "$table_info" | cut -d'|' -f1) + local table_title=$(echo "$table_info" | cut -d'|' -f2) + + # Find corresponding destination table + local dest_table_id="${table_mapping[$table_name]}" + + if [[ -n "$dest_table_id" ]]; then + migrate_table_data "$SOURCE_BASE_ID" "$BASE_ID" "$source_table_id" "$dest_table_id" "$table_name" + else + print_warning "No destination table found for: $table_name (skipping)" + fi + else + print_warning "Could not find table info for ID: $source_table_id (skipping)" + fi + done + else + print_error "Failed to get source table information for migration" + fi + + print_status "Data migration completed" + print_status "========================" + fi + print_status "" print_status "============================================================" print_success "NocoDB Setup completed successfully!" print_status "============================================================" print_status "" - print_status "Created new base: Alberta Influence Campaign Tool (ID: $BASE_ID)" + print_status "Created new base: BNKops Influence Campaign Tool (ID: $BASE_ID)" print_status "Created tables:" print_status " - influence_representatives (ID: $REPRESENTATIVES_TABLE_ID)" print_status " - influence_email_logs (ID: $EMAIL_LOGS_TABLE_ID)" @@ -907,7 +1533,7 @@ main() { print_status "============================================================" print_status "" - print_status "Created new base: Alberta Influence Campaign Tool (ID: $BASE_ID)" + print_status "Created new base: BNKops Influence Campaign Tool (ID: $BASE_ID)" print_status "Updated .env file with project ID and all table IDs" print_status "" print_status "Next steps:" @@ -918,21 +1544,20 @@ main() { print_status "5. Access the admin panel at: http://localhost:3333/admin.html" print_status "" - print_success "Your Alberta Influence Campaign Tool is ready to use!" + print_warning "IMPORTANT: This script created a NEW base. Your existing data was NOT modified." + print_warning "Updated .env file with new project ID and table IDs." + print_warning "A backup of your previous .env file was created with a timestamp." + + if [[ "$MIGRATE_DATA" == "true" && -n "$SOURCE_BASE_ID" && -n "$SOURCE_TABLE_IDS" ]]; then + print_warning "Data migration completed. Please verify your data in the new base." + print_warning "The original base remains unchanged as a backup." + fi + + print_status "" + print_success "Your BNKops Influence Campaign Tool is ready to use!" } # Check if script is being run directly if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then - # Check for command line arguments - if [[ "$1" == "--help" || "$1" == "-h" ]]; then - echo "Usage: $0 [OPTIONS]" - echo "" - echo "Options:" - echo " --help, -h Show this help message" - echo "" - echo "Creates a new NocoDB base with all required tables for the Influence Campaign Tool" - exit 0 - else - main "$@" - fi + main "$@" fi \ No newline at end of file