Bunch of buag fixes and updates

This commit is contained in:
admin 2025-09-24 12:37:26 -06:00
parent a26d9b8d78
commit d29ffa6300
33 changed files with 3085 additions and 200 deletions

View File

@ -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

View File

@ -50,10 +50,23 @@ class AuthController {
// Update last login time
try {
const userId = user.Id || user.id;
// 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;

View File

@ -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,12 +578,31 @@ class CampaignsController {
});
}
// Get representatives
const result = await representAPI.getRepresentativesByPostalCode(postalCode);
// First check cache for representatives
const formattedPostalCode = postalCode.replace(/\s/g, '').toUpperCase();
let representatives = [];
let result = null;
// 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);
}
// 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
let representatives = [];
// Add concordance representatives (if any)
if (result.representatives_concordance && result.representatives_concordance.length > 0) {
representatives = representatives.concat(result.representatives_concordance);
@ -555,14 +613,21 @@ class CampaignsController {
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) {
return res.json({
success: false,
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'
}
});

View File

@ -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,

View File

@ -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',
success: true,
source: 'Local Cache',
data: {
postalCode: formattedPostalCode,
location: {
city: cachedData[0]?.city || 'Alberta',
province: 'AB'
},
representatives: cachedData
}
});
}
} catch (cacheError) {
@ -86,6 +97,9 @@ class RepresentativesController {
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}`);
console.log(`Total representatives found: ${representatives.length}`);
@ -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,

View File

@ -588,6 +588,10 @@ Sincerely,
<input type="checkbox" id="create-show-count" name="show_email_count" checked>
<label for="create-show-count">📊 Show Email Count</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="create-allow-editing" name="allow_email_editing">
<label for="create-allow-editing">✏️ Allow Email Editing</label>
</div>
</div>
</div>
@ -681,6 +685,10 @@ Sincerely,
<input type="checkbox" id="edit-show-count" name="show_email_count">
<label for="edit-show-count">Show Email Count</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="edit-allow-editing" name="allow_email_editing">
<label for="edit-allow-editing">✏️ Allow Email Editing</label>
</div>
</div>
</div>

View File

@ -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 @@
<div class="progress-steps">
<div class="step active" id="step-info">Enter Your Info</div>
<div class="step" id="step-postal">Find Representatives</div>
<div class="step" id="step-send">Send Emails</div>
<div class="step" id="step-send">Send Messages</div>
</div>
<!-- User Information Form -->
@ -281,11 +318,22 @@
</div>
<!-- Email Preview -->
<div id="email-preview" class="email-preview" style="display: none;">
<div id="email-preview" class="email-preview preview-mode" style="display: none;">
<h3>📧 Email Preview</h3>
<p>This is the message that will be sent to your representatives:</p>
<p id="preview-description">This is the message that will be sent to your representatives:</p>
<!-- Read-only preview -->
<div class="email-subject" id="preview-subject"></div>
<div class="email-body" id="preview-body"></div>
<!-- Editable fields -->
<input type="text" class="email-edit-subject" id="edit-subject" placeholder="Email Subject">
<textarea class="email-edit-body" id="edit-body" placeholder="Email Body"></textarea>
<div class="email-edit-actions">
<button type="button" class="btn btn-secondary" id="preview-email-btn">👁️ Preview</button>
<button type="button" class="btn btn-primary" id="save-email-btn">💾 Save Changes</button>
</div>
</div>
<!-- Representatives Section -->
@ -321,6 +369,11 @@
<div id="error-message" class="error-message" style="display: none;"></div>
</div>
<footer style="text-align: center; margin-top: 2rem; padding: 1rem; border-top: 1px solid #e9ecef;">
<p>&copy; 2025 <a href="https://bnkops.com/" target="_blank">BNKops</a> Influence Tool. Connect with democracy.</p>
<p><small><a href="terms.html" target="_blank">Terms of Use & Privacy Notice</a> | <a href="index.html">Return to Main Page</a></small></p>
</footer>
<script src="/js/campaign.js"></script>
</body>
</html>

View File

@ -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;
}
}

View File

@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Email Testing Interface - Alberta Influence Campaign</title>
<title>Email Testing Interface - BNKops Influence Campaign</title>
<link rel="stylesheet" href="css/styles.css">
<style>
.test-container {

View File

@ -5,22 +5,33 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>BNKops Influence Campaign Tool</title>
<link rel="icon" href="data:,">
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="" />
<link rel="stylesheet" href="css/styles.css">
</head>
<body>
<div class="container">
<header>
<h1>BNKops Influence Tool</h1>
<h1><a href="https://bnkops.com/" target="_blank" style="color: inherit; text-decoration: underline;">BNKops</a> Influence Tool</h1>
<p>Connect with your elected representatives across all levels of government</p>
</header>
<main>
<!-- Postal Code Lookup Section -->
<section id="postal-lookup">
<!-- Postal Code Input Section -->
<section id="postal-input-section">
<div class="map-header">
<h2>Find Your Representatives</h2>
</div>
<!-- Postal Code Input -->
<div class="postal-input-section">
<form id="postal-form">
<div class="form-group">
<label for="postal-code">Enter your postal code:</label>
<label for="postal-code">Enter your Alberta postal code:</label>
<div class="input-group">
<input
type="text"
@ -43,6 +54,7 @@
<div class="spinner"></div>
<p>Looking up your representatives...</p>
</div>
</div>
</section>
<!-- Representatives Display Section -->
@ -55,6 +67,15 @@
<div id="representatives-container">
<!-- Representatives will be dynamically inserted here -->
</div>
<!-- Map showing office locations -->
<div class="map-header">
<h3>Representative Office Locations</h3>
</div>
<div id="map-container" class="map-container">
<div id="main-map" style="height: 400px; width: 100%;"></div>
</div>
</section>
<!-- Email Compose Modal -->
@ -104,27 +125,82 @@
<div class="form-actions">
<button type="button" id="cancel-email" class="btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary">Send Email</button>
<button type="submit" class="btn btn-primary">Preview Email</button>
</div>
</form>
</div>
</div>
</div>
<!-- Email Preview Modal -->
<div id="email-preview-modal" class="modal" style="display: none;">
<div class="modal-content preview-modal">
<div class="modal-header">
<h3>Email Preview</h3>
<span class="close-btn" id="close-preview-modal">&times;</span>
</div>
<div class="modal-body">
<div class="preview-section">
<h4>Email Details</h4>
<div class="email-details">
<div class="detail-row">
<strong>To:</strong> <span id="preview-recipient"></span>
</div>
<div class="detail-row">
<strong>From:</strong> <span id="preview-sender"></span>
</div>
<div class="detail-row">
<strong>Subject:</strong> <span id="preview-subject"></span>
</div>
</div>
</div>
<div class="preview-section">
<h4>Email Content Preview</h4>
<div id="preview-content" class="email-preview-content">
<!-- Email HTML preview will be inserted here -->
</div>
</div>
<div class="modal-actions">
<button type="button" id="edit-email" class="btn btn-secondary">
✏️ Edit Email
</button>
<button type="button" id="confirm-send" class="btn btn-primary">
📧 Send Email
</button>
<button type="button" id="cancel-preview" class="btn btn-outline">
Cancel
</button>
</div>
</div>
</div>
</div>
<!-- Success/Error Messages -->
<div id="message-display" class="message-display" style="display: none;"></div>
</main>
<footer>
<p>&copy; 2025 <a href="https://bnkops.com/" target="_blank">BNKops</a> Influence Campaign Tool. Connect with democracy.</p>
<p>&copy; 2025 <a href="https://bnkops.com/" target="_blank">BNKops</a> Influence Tool. Connect with democracy.</p>
<p><small>This tool uses the <a href="https://represent.opennorth.ca" target="_blank">Represent API</a> by Open North to find your representatives.</small></p>
<p><small><a href="terms.html" target="_blank">Terms of Use & Privacy Notice</a></small></p>
<div class="footer-actions">
<a href="/login.html" class="btn btn-secondary">Admin Login</a>
</div>
</footer>
</div>
<!-- Leaflet JavaScript -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""></script>
<script src="js/api-client.js"></script>
<script src="js/postal-lookup.js"></script>
<script src="js/representatives-display.js"></script>
<script src="js/email-composer.js"></script>
<script src="js/representatives-map.js"></script>
<script src="js/main.js"></script>
</body>
</html>

View File

@ -251,6 +251,7 @@ class AdminPanel {
email_subject: formData.get('email_subject'),
email_body: formData.get('email_body'),
call_to_action: formData.get('call_to_action'),
status: formData.get('status'),
allow_smtp_email: formData.get('allow_smtp_email') === 'on',
allow_mailto_link: formData.get('allow_mailto_link') === 'on',
collect_user_info: formData.get('collect_user_info') === 'on',
@ -302,6 +303,7 @@ class AdminPanel {
form.querySelector('[name="allow_mailto_link"]').checked = campaign.allow_mailto_link;
form.querySelector('[name="collect_user_info"]').checked = campaign.collect_user_info;
form.querySelector('[name="show_email_count"]').checked = campaign.show_email_count;
form.querySelector('[name="allow_email_editing"]').checked = campaign.allow_email_editing;
// Government levels
const targetLevels = campaign.target_government_levels ?

View File

@ -71,6 +71,11 @@ class APIClient {
async sendEmail(emailData) {
return this.post('/emails/send', emailData);
}
// Preview email before sending
async previewEmail(emailData) {
return this.post('/emails/preview', emailData);
}
}
// Create global instance

View File

@ -66,10 +66,8 @@ class CampaignPage {
document.getElementById('call-to-action').style.display = 'block';
}
// Show email preview
document.getElementById('preview-subject').textContent = this.campaign.email_subject;
document.getElementById('preview-body').textContent = this.campaign.email_body;
document.getElementById('email-preview').style.display = 'block';
// Set up email preview
this.setupEmailPreview();
// Set up email method options
this.setupEmailMethodOptions();
@ -122,6 +120,115 @@ class CampaignPage {
}
}
setupEmailPreview() {
const emailPreview = document.getElementById('email-preview');
const previewDescription = document.getElementById('preview-description');
// Store original email content
this.originalEmailSubject = this.campaign.email_subject;
this.originalEmailBody = this.campaign.email_body;
this.currentEmailSubject = this.campaign.email_subject;
this.currentEmailBody = this.campaign.email_body;
// Set up preview content
document.getElementById('preview-subject').textContent = this.currentEmailSubject;
document.getElementById('preview-body').textContent = this.currentEmailBody;
// Set up editable fields
document.getElementById('edit-subject').value = this.currentEmailSubject;
document.getElementById('edit-body').value = this.currentEmailBody;
if (this.campaign.allow_email_editing) {
// Enable editing mode
emailPreview.classList.remove('preview-mode');
emailPreview.classList.add('edit-mode');
previewDescription.textContent = 'You can edit this message before sending to your representatives:';
// Set up event listeners for editing
this.setupEmailEditingListeners();
} else {
// Read-only preview mode
emailPreview.classList.remove('edit-mode');
emailPreview.classList.add('preview-mode');
previewDescription.textContent = 'This is the message that will be sent to your representatives:';
}
emailPreview.style.display = 'block';
}
setupEmailEditingListeners() {
const editSubject = document.getElementById('edit-subject');
const editBody = document.getElementById('edit-body');
const previewBtn = document.getElementById('preview-email-btn');
const saveBtn = document.getElementById('save-email-btn');
// Auto-update current content as user types
editSubject.addEventListener('input', (e) => {
this.currentEmailSubject = e.target.value;
});
editBody.addEventListener('input', (e) => {
this.currentEmailBody = e.target.value;
});
// Preview button - toggle between edit and preview mode
previewBtn.addEventListener('click', () => {
this.toggleEmailPreview();
});
// Save button - save changes
saveBtn.addEventListener('click', () => {
this.saveEmailChanges();
});
}
toggleEmailPreview() {
const emailPreview = document.getElementById('email-preview');
const previewBtn = document.getElementById('preview-email-btn');
if (emailPreview.classList.contains('edit-mode')) {
// Switch to preview mode
document.getElementById('preview-subject').textContent = this.currentEmailSubject;
document.getElementById('preview-body').textContent = this.currentEmailBody;
emailPreview.classList.remove('edit-mode');
emailPreview.classList.add('preview-mode');
previewBtn.textContent = '✏️ Edit';
} else {
// Switch to edit mode
emailPreview.classList.remove('preview-mode');
emailPreview.classList.add('edit-mode');
previewBtn.textContent = '👁️ Preview';
}
}
saveEmailChanges() {
// Update the current values and show confirmation
document.getElementById('preview-subject').textContent = this.currentEmailSubject;
document.getElementById('preview-body').textContent = this.currentEmailBody;
// Show success message
this.showMessage('Email content updated successfully!', 'success');
// Switch to preview mode
const emailPreview = document.getElementById('email-preview');
const previewBtn = document.getElementById('preview-email-btn');
emailPreview.classList.remove('edit-mode');
emailPreview.classList.add('preview-mode');
previewBtn.textContent = '✏️ Edit';
}
showMessage(message, type = 'info') {
// Use existing message display system if available
if (window.messageDisplay) {
window.messageDisplay.show(message, type);
} else {
// Fallback to alert
alert(message);
}
}
formatPostalCode(e) {
let value = e.target.value.replace(/\s/g, '').toUpperCase();
if (value.length > 3) {
@ -364,8 +471,8 @@ class CampaignPage {
}
openMailtoLink(recipientEmail) {
const subject = encodeURIComponent(this.campaign.email_subject);
const body = encodeURIComponent(this.campaign.email_body);
const subject = encodeURIComponent(this.currentEmailSubject || this.campaign.email_subject);
const body = encodeURIComponent(this.currentEmailBody || this.campaign.email_body);
const mailtoUrl = `mailto:${recipientEmail}?subject=${subject}&body=${body}`;
// Track the mailto click
@ -378,12 +485,7 @@ class CampaignPage {
this.showLoading('Sending email...');
try {
const response = await fetch(`/api/campaigns/${this.campaignSlug}/send-email`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
const emailData = {
userEmail: this.userInfo.userEmail,
userName: this.userInfo.userName,
postalCode: this.userInfo.postalCode,
@ -392,7 +494,20 @@ class CampaignPage {
recipientTitle,
recipientLevel,
emailMethod: 'smtp'
})
};
// Include custom email content if email editing is enabled
if (this.campaign.allow_email_editing) {
emailData.customEmailSubject = this.currentEmailSubject || this.campaign.email_subject;
emailData.customEmailBody = this.currentEmailBody || this.campaign.email_body;
}
const response = await fetch(`/api/campaigns/${this.campaignSlug}/send-email`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(emailData)
});
const data = await response.json();

View File

@ -2,35 +2,68 @@
class EmailComposer {
constructor() {
this.modal = document.getElementById('email-modal');
this.previewModal = document.getElementById('email-preview-modal');
this.form = document.getElementById('email-form');
this.closeBtn = document.getElementById('close-modal');
this.closePreviewBtn = document.getElementById('close-preview-modal');
this.cancelBtn = document.getElementById('cancel-email');
this.cancelPreviewBtn = document.getElementById('cancel-preview');
this.editBtn = document.getElementById('edit-email');
this.confirmSendBtn = document.getElementById('confirm-send');
this.messageTextarea = document.getElementById('email-message');
this.charCounter = document.querySelector('.char-counter');
this.currentRecipient = null;
this.currentEmailData = null;
this.lastPreviewTime = 0; // Track last preview request time
this.init();
}
init() {
// Modal controls
this.closeBtn.addEventListener('click', () => this.closeModal());
this.closePreviewBtn.addEventListener('click', () => this.closePreviewModal());
this.cancelBtn.addEventListener('click', () => this.closeModal());
this.cancelPreviewBtn.addEventListener('click', () => this.closePreviewModal());
this.editBtn.addEventListener('click', () => this.editEmail());
this.confirmSendBtn.addEventListener('click', () => this.confirmSend());
// Click outside modal to close
this.modal.addEventListener('click', (e) => {
if (e.target === this.modal) this.closeModal();
});
this.previewModal.addEventListener('click', (e) => {
if (e.target === this.previewModal) this.closePreviewModal();
});
// Form handling
this.form.addEventListener('submit', (e) => this.handleSubmit(e));
// Form handling - now shows preview instead of sending directly
this.form.addEventListener('submit', (e) => this.handlePreview(e));
// Character counter
this.messageTextarea.addEventListener('input', () => this.updateCharCounter());
// Escape key to close modal
// Add event listener to sender name field to update subject dynamically
const senderNameField = document.getElementById('sender-name');
const subjectField = document.getElementById('email-subject');
const postalCodeField = document.getElementById('sender-postal-code');
senderNameField.addEventListener('input', () => {
const senderName = senderNameField.value.trim() || 'your constituent';
const postalCode = postalCodeField.value.trim();
if (postalCode) {
subjectField.value = `Message from ${senderName} from ${postalCode}`;
}
});
// Escape key to close modals
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && this.modal.style.display === 'block') {
if (e.key === 'Escape') {
if (this.previewModal.style.display === 'block') {
this.closePreviewModal();
} else if (this.modal.style.display === 'block') {
this.closeModal();
}
}
});
}
@ -57,7 +90,7 @@ class EmailComposer {
document.getElementById('email-message').value = '';
// Set default subject
document.getElementById('email-subject').value = `Message from your constituent in ${postalCode}`;
document.getElementById('email-subject').value = `Message from your constituent from ${postalCode}`;
this.updateCharCounter();
this.modal.style.display = 'block';
@ -68,7 +101,24 @@ class EmailComposer {
closeModal() {
this.modal.style.display = 'none';
// Only clear data if we're not showing preview (user is canceling)
if (this.previewModal.style.display !== 'block') {
this.currentRecipient = null;
this.currentEmailData = null;
}
}
closePreviewModal() {
this.previewModal.style.display = 'none';
// Clear email data when closing preview (user canceling)
this.currentRecipient = null;
this.currentEmailData = null;
}
editEmail() {
// Close preview modal and return to compose modal without clearing data
this.previewModal.style.display = 'none';
this.modal.style.display = 'block';
}
updateCharCounter() {
@ -141,9 +191,17 @@ class EmailComposer {
return suspiciousPatterns.some(pattern => pattern.test(text));
}
async handleSubmit(e) {
async handlePreview(e) {
e.preventDefault();
// Prevent duplicate calls within 2 seconds
const currentTime = Date.now();
if (currentTime - this.lastPreviewTime < 2000) {
console.log('Preview request blocked - too soon since last request');
return;
}
this.lastPreviewTime = currentTime;
const errors = this.validateForm();
if (errors.length > 0) {
window.messageDisplay.show(errors.join('<br>'), 'error');
@ -155,22 +213,118 @@ class EmailComposer {
try {
submitButton.disabled = true;
submitButton.textContent = 'Sending...';
submitButton.textContent = 'Loading Preview...';
const emailData = {
this.currentEmailData = {
recipientEmail: document.getElementById('recipient-email').value,
senderName: document.getElementById('sender-name').value.trim(),
senderEmail: document.getElementById('sender-email').value.trim(),
subject: document.getElementById('email-subject').value.trim(),
message: document.getElementById('email-message').value.trim(),
postalCode: document.getElementById('sender-postal-code').value
postalCode: document.getElementById('sender-postal-code').value,
recipientName: this.currentRecipient ? this.currentRecipient.name : null
};
const result = await window.apiClient.sendEmail(emailData);
const preview = await window.apiClient.previewEmail(this.currentEmailData);
if (preview.success) {
this.showPreview(preview.preview);
this.previewModal.style.display = 'block'; // Show preview modal first
this.closeModal(); // Close the compose modal
} else {
throw new Error(preview.message || 'Failed to generate preview');
}
} catch (error) {
console.error('Email preview failed:', error);
window.messageDisplay.show(`Failed to generate preview: ${error.message}`, 'error');
} finally {
submitButton.disabled = false;
submitButton.textContent = originalText;
}
}
showPreview(preview) {
// Populate preview modal with email details
document.getElementById('preview-recipient').textContent = preview.to;
document.getElementById('preview-sender').textContent = `${this.currentEmailData.senderName} <${this.currentEmailData.senderEmail}>`;
document.getElementById('preview-subject').textContent = preview.subject;
// Show the HTML preview content using iframe for complete isolation
const previewContent = document.getElementById('preview-content');
if (preview.html) {
// Use iframe to completely isolate the email HTML from the parent page
const iframe = document.createElement('iframe');
iframe.style.width = '100%';
iframe.style.minHeight = '400px';
iframe.style.border = '1px solid #dee2e6';
iframe.style.borderRadius = '6px';
iframe.style.backgroundColor = '#ffffff';
iframe.sandbox = 'allow-same-origin'; // Safe sandbox settings
// Clear previous content and add iframe
previewContent.innerHTML = '';
previewContent.appendChild(iframe);
// Write the HTML content to the iframe
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
iframeDoc.open();
iframeDoc.write(preview.html);
iframeDoc.close();
// Auto-resize iframe to content height
iframe.onload = () => {
try {
const body = iframe.contentDocument.body;
const html = iframe.contentDocument.documentElement;
const height = Math.max(
body.scrollHeight,
body.offsetHeight,
html.clientHeight,
html.scrollHeight,
html.offsetHeight
);
iframe.style.height = Math.min(height + 20, 600) + 'px'; // Max height of 600px
} catch (e) {
// Fallback height if auto-resize fails
iframe.style.height = '400px';
}
};
} else if (preview.text) {
previewContent.innerHTML = `<pre style="white-space: pre-wrap; font-family: inherit; padding: 20px; background-color: #f8f9fa; border-radius: 6px; border: 1px solid #dee2e6;">${this.escapeHtml(preview.text)}</pre>`;
} else {
previewContent.innerHTML = '<p style="padding: 20px; text-align: center; color: #666;">No preview content available</p>';
}
}
// Escape HTML to prevent injection when showing text content
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
async confirmSend() {
if (!this.currentEmailData) {
window.messageDisplay.show('No email data to send', 'error');
return;
}
const confirmButton = this.confirmSendBtn;
const originalText = confirmButton.textContent;
try {
confirmButton.disabled = true;
confirmButton.textContent = 'Sending...';
const result = await window.apiClient.sendEmail(this.currentEmailData);
if (result.success) {
window.messageDisplay.show('Email sent successfully! Your representative will receive your message.', 'success');
this.closeModal();
this.closePreviewModal();
this.currentEmailData = null;
} else {
throw new Error(result.message || 'Failed to send email');
}
@ -195,8 +349,8 @@ class EmailComposer {
window.messageDisplay.show(`Failed to send email: ${error.message}`, 'error');
}
} finally {
submitButton.disabled = false;
submitButton.textContent = originalText;
confirmButton.disabled = false;
confirmButton.textContent = originalText;
}
}

View File

@ -51,7 +51,7 @@ class EmailTesting {
try {
const response = await this.apiClient.post('/api/emails/test', {
subject: 'Quick Test Email',
message: 'This is a quick test email sent from the Alberta Influence Campaign Tool email testing interface.'
message: 'This is a quick test email sent from the BNKops Influence Campaign Tool email testing interface.'
});
if (response.success) {

View File

@ -13,15 +13,38 @@ class MainApp {
// Add global error handling
window.addEventListener('error', (e) => {
// Only log and show message for actual errors, not null/undefined
if (e.error) {
console.error('Global error:', e.error);
window.messageDisplay.show('An unexpected error occurred. Please refresh the page and try again.', 'error');
console.error('Error details:', {
message: e.message,
filename: e.filename,
lineno: e.lineno,
colno: e.colno,
error: e.error
});
window.messageDisplay?.show('An unexpected error occurred. Please refresh the page and try again.', 'error');
} else {
// Just log these non-critical errors without showing popup
console.log('Non-critical error event:', {
message: e.message,
filename: e.filename,
lineno: e.lineno,
colno: e.colno,
type: e.type
});
}
});
// Add unhandled promise rejection handling
window.addEventListener('unhandledrejection', (e) => {
if (e.reason) {
console.error('Unhandled promise rejection:', e.reason);
window.messageDisplay.show('An unexpected error occurred. Please try again.', 'error');
window.messageDisplay?.show('An unexpected error occurred. Please try again.', 'error');
e.preventDefault();
} else {
console.log('Non-critical promise rejection:', e);
}
});
}

View File

@ -136,9 +136,8 @@ class PostalLookup {
locationText += `${data.city}, ${data.province}`;
}
if (data.source || apiResponse.source) {
locationText += `Data source: ${data.source || apiResponse.source}`;
locationText += `Pulled From: ${data.source || apiResponse.source}`;
}
locationText += ` • Data source: api`;
this.locationDetails.textContent = locationText;
// Show representatives

View File

@ -117,7 +117,7 @@ class RepresentativesDisplay {
data-name="${name}"
data-office="${office}"
data-district="${district}">
Send Email
📧 Send Email
</button>` :
'<span class="text-muted">No email available</span>';
@ -131,8 +131,11 @@ class RepresentativesDisplay {
📞 Call
</button>` : '';
// Add visit buttons for all available office addresses
const visitButtons = this.createVisitButtons(rep.offices || [], name, office);
const profileUrl = rep.url ?
`<a href="${rep.url}" target="_blank" class="btn btn-secondary">View Profile</a>` : '';
`<a href="${rep.url}" target="_blank" class="btn btn-secondary">👤 View Profile</a>` : '';
// Generate initials for fallback
const initials = name.split(' ')
@ -145,7 +148,7 @@ class RepresentativesDisplay {
`<div class="rep-photo">
<img src="${photoUrl}"
alt="${name}"
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';"
data-fallback-initials="${initials}"
loading="lazy">
<div class="rep-photo-fallback" style="display: none;">
${initials}
@ -176,6 +179,7 @@ class RepresentativesDisplay {
${callButton}
${profileUrl}
</div>
${visitButtons ? `<div class="rep-visit-buttons">${visitButtons}</div>` : ''}
</div>
</div>
`;
@ -185,8 +189,63 @@ class RepresentativesDisplay {
const phoneNumbers = [];
if (Array.isArray(offices)) {
// Priority order for office types (prefer local over remote)
const officePriorities = ['constituency', 'district', 'local', 'regional', 'legislature'];
// First, try to find offices with Alberta addresses (for MPs)
const albertaOffices = offices.filter(office => {
const address = office.postal || office.address || '';
return address.toLowerCase().includes('alberta') ||
address.toLowerCase().includes(' ab ') ||
address.toLowerCase().includes('edmonton') ||
address.toLowerCase().includes('calgary') ||
address.toLowerCase().includes('red deer') ||
address.toLowerCase().includes('lethbridge') ||
address.toLowerCase().includes('medicine hat');
});
// Add phone numbers from Alberta offices first
if (albertaOffices.length > 0) {
for (const priority of officePriorities) {
const priorityOffice = albertaOffices.find(office =>
office.type === priority && office.tel
);
if (priorityOffice) {
phoneNumbers.push({
number: priorityOffice.tel,
type: priorityOffice.type || 'office'
});
}
}
// Add any remaining Alberta office phone numbers
albertaOffices.forEach(office => {
if (office.tel && !phoneNumbers.find(p => p.number === office.tel)) {
phoneNumbers.push({
number: office.tel,
type: office.type || 'office'
});
}
});
}
// Then add phone numbers from other offices by priority
for (const priority of officePriorities) {
const priorityOffice = offices.find(office =>
office.type === priority && office.tel &&
!phoneNumbers.find(p => p.number === office.tel)
);
if (priorityOffice) {
phoneNumbers.push({
number: priorityOffice.tel,
type: priorityOffice.type || 'office'
});
}
}
// Finally, add any remaining phone numbers
offices.forEach(office => {
if (office.tel) {
if (office.tel && !phoneNumbers.find(p => p.number === office.tel)) {
phoneNumbers.push({
number: office.tel,
type: office.type || 'office'
@ -198,6 +257,102 @@ class RepresentativesDisplay {
return phoneNumbers;
}
createVisitButtons(offices, repName, repOffice) {
if (!Array.isArray(offices) || offices.length === 0) {
return '';
}
const validOffices = offices.filter(office => office.postal || office.address);
if (validOffices.length === 0) {
return '';
}
// Sort offices by priority (local first)
const sortedOffices = validOffices.sort((a, b) => {
const aAddress = (a.postal || a.address || '').toLowerCase();
const bAddress = (b.postal || b.address || '').toLowerCase();
// Check if address is in Alberta
const aIsAlberta = aAddress.includes('alberta') || aAddress.includes(' ab ') ||
aAddress.includes('edmonton') || aAddress.includes('calgary');
const bIsAlberta = bAddress.includes('alberta') || bAddress.includes(' ab ') ||
bAddress.includes('edmonton') || bAddress.includes('calgary');
if (aIsAlberta && !bIsAlberta) return -1;
if (!aIsAlberta && bIsAlberta) return 1;
// If both are Alberta or both are not, prefer constituency over legislature
const typePriority = { 'constituency': 1, 'district': 2, 'local': 3, 'regional': 4, 'legislature': 5 };
const aPriority = typePriority[a.type] || 6;
const bPriority = typePriority[b.type] || 6;
return aPriority - bPriority;
});
return sortedOffices.map(office => {
const address = office.postal || office.address;
const officeType = this.getOfficeTypeLabel(office.type, address);
const isLocal = this.isLocalAddress(address);
return `
<button class="btn btn-sm btn-secondary visit-office"
data-address="${address}"
data-name="${repName}"
data-office="${repOffice}"
title="Visit ${officeType} office">
🗺 ${officeType}${isLocal ? ' 📍' : ''}
<small class="office-location">${this.getShortAddress(address)}</small>
</button>
`;
}).join('');
}
getOfficeTypeLabel(type, address) {
if (!type) {
// Try to determine type from address
const addr = address.toLowerCase();
if (addr.includes('ottawa') || addr.includes('parliament') || addr.includes('house of commons')) {
return 'Ottawa';
} else if (addr.includes('legislature') || addr.includes('provincial')) {
return 'Legislature';
} else if (addr.includes('city hall')) {
return 'City Hall';
}
return 'Office';
}
const typeLabels = {
'constituency': 'Local Office',
'district': 'District Office',
'local': 'Local Office',
'regional': 'Regional Office',
'legislature': 'Legislature'
};
return typeLabels[type] || type.charAt(0).toUpperCase() + type.slice(1);
}
isLocalAddress(address) {
const addr = address.toLowerCase();
return addr.includes('alberta') || addr.includes(' ab ') ||
addr.includes('edmonton') || addr.includes('calgary') ||
addr.includes('red deer') || addr.includes('lethbridge') ||
addr.includes('medicine hat');
}
getShortAddress(address) {
// Extract city and province/state for short display
const parts = address.split(',');
if (parts.length >= 2) {
const city = parts[parts.length - 2].trim();
const province = parts[parts.length - 1].trim();
return `${city}, ${province}`;
}
// Fallback: just show first part
return parts[0].trim();
}
attachEventListeners() {
// Add event listeners for compose email buttons
const composeButtons = this.container.querySelectorAll('.compose-email');
@ -229,6 +384,33 @@ class RepresentativesDisplay {
this.handleCallClick(phone, name, office, officeType);
});
});
// Add event listeners for visit buttons
const visitButtons = this.container.querySelectorAll('.visit-office');
visitButtons.forEach(button => {
button.addEventListener('click', (e) => {
e.preventDefault();
// Use currentTarget to ensure we get the button, not nested elements
const address = button.dataset.address;
const name = button.dataset.name;
const office = button.dataset.office;
this.handleVisitClick(address, name, office);
});
});
// Add event listeners for image error handling
const repImages = this.container.querySelectorAll('.rep-photo img');
repImages.forEach(img => {
img.addEventListener('error', (e) => {
// Hide the image and show the fallback
e.target.style.display = 'none';
const fallback = e.target.nextElementSibling;
if (fallback && fallback.classList.contains('rep-photo-fallback')) {
fallback.style.display = 'flex';
}
});
});
}
handleCallClick(phone, name, office, officeType) {
@ -247,6 +429,51 @@ class RepresentativesDisplay {
window.location.href = telLink;
}
}
handleVisitClick(address, name, office) {
// Clean and format the address for URL encoding
const cleanAddress = address.replace(/\n/g, ', ').trim();
// Show confirmation dialog
const message = `Open directions to ${name}'s office?\n\nAddress: ${cleanAddress}`;
if (confirm(message)) {
// Create maps URL - this will work on most platforms
// For mobile devices, it will open the default maps app
// For desktop, it will open Google Maps in browser
const encodedAddress = encodeURIComponent(cleanAddress);
// Try different map services based on user agent
const userAgent = navigator.userAgent.toLowerCase();
let mapsUrl;
if (userAgent.includes('iphone') || userAgent.includes('ipad')) {
// iOS - use Apple Maps
mapsUrl = `maps://maps.apple.com/?q=${encodedAddress}`;
// Fallback to Google Maps if Apple Maps doesn't work
setTimeout(() => {
window.open(`https://www.google.com/maps/search/${encodedAddress}`, '_blank');
}, 500);
window.location.href = mapsUrl;
} else if (userAgent.includes('android')) {
// Android - use Google Maps app if available
mapsUrl = `geo:0,0?q=${encodedAddress}`;
// Fallback to Google Maps web
setTimeout(() => {
window.open(`https://www.google.com/maps/search/${encodedAddress}`, '_blank');
}, 500);
window.location.href = mapsUrl;
} else {
// Desktop or other - open Google Maps in new tab
mapsUrl = `https://www.google.com/maps/search/${encodedAddress}`;
window.open(mapsUrl, '_blank');
}
}
}
}
// Initialize when DOM is loaded

View File

@ -0,0 +1,583 @@
/**
* Representatives Map Module
* Handles map initialization, office location display, and popup cards
*/
// Map state
let representativesMap = null;
let representativeMarkers = [];
let currentPostalCode = null;
// Office location icons
const officeIcons = {
federal: L.divIcon({
className: 'office-marker federal',
html: '<div class="marker-content">🏛️</div>',
iconSize: [40, 40],
iconAnchor: [20, 40],
popupAnchor: [0, -40]
}),
provincial: L.divIcon({
className: 'office-marker provincial',
html: '<div class="marker-content">🏢</div>',
iconSize: [40, 40],
iconAnchor: [20, 40],
popupAnchor: [0, -40]
}),
municipal: L.divIcon({
className: 'office-marker municipal',
html: '<div class="marker-content">🏛️</div>',
iconSize: [40, 40],
iconAnchor: [20, 40],
popupAnchor: [0, -40]
})
};
// Initialize the representatives map
function initializeRepresentativesMap() {
const mapContainer = document.getElementById('main-map');
if (!mapContainer) {
console.warn('Map container not found');
return;
}
// Avoid double initialization
if (representativesMap) {
console.log('Map already initialized, invalidating size instead');
representativesMap.invalidateSize();
return;
}
// Check if Leaflet is available
if (typeof L === 'undefined') {
console.error('Leaflet (L) is not defined. Map initialization failed.');
return;
}
// We'll initialize the map even if not visible, then invalidate size when needed
console.log('Initializing representatives map...');
// Center on Alberta
representativesMap = L.map('main-map').setView([53.9333, -116.5765], 6);
// Add tile layer
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19,
minZoom: 2
}).addTo(representativesMap);
// Trigger size invalidation after a brief moment to ensure proper rendering
setTimeout(() => {
if (representativesMap) {
representativesMap.invalidateSize();
}
}, 200);
}
// Clear all representative markers from the map
function clearRepresentativeMarkers() {
representativeMarkers.forEach(marker => {
representativesMap.removeLayer(marker);
});
representativeMarkers = [];
}
// Add representative offices to the map
function displayRepresentativeOffices(representatives, postalCode) {
// Initialize map if not already done
if (!representativesMap) {
console.log('Map not initialized, initializing now...');
initializeRepresentativesMap();
}
if (!representativesMap) {
console.error('Failed to initialize map');
return;
}
clearRepresentativeMarkers();
currentPostalCode = postalCode;
const validOffices = [];
let bounds = [];
console.log('Processing representatives for map display:', representatives.length);
// Group representatives by office location to handle shared addresses
const locationGroups = new Map();
representatives.forEach((rep, index) => {
console.log(`Processing representative ${index + 1}:`, rep.name, rep.representative_set_name);
// Try to get office location from various sources
const offices = getOfficeLocations(rep);
console.log(`Found ${offices.length} offices for ${rep.name}:`, offices);
offices.forEach((office, officeIndex) => {
console.log(`Office ${officeIndex + 1} for ${rep.name}:`, office);
if (office.lat && office.lng) {
const locationKey = `${office.lat.toFixed(6)},${office.lng.toFixed(6)}`;
if (!locationGroups.has(locationKey)) {
locationGroups.set(locationKey, {
lat: office.lat,
lng: office.lng,
address: office.address,
representatives: [],
offices: []
});
}
locationGroups.get(locationKey).representatives.push(rep);
locationGroups.get(locationKey).offices.push(office);
validOffices.push({ rep, office });
} else {
console.log(`No coordinates found for ${rep.name} office:`, office);
}
});
});
// Create markers for each location group
let offsetIndex = 0;
locationGroups.forEach((locationGroup, locationKey) => {
console.log(`Creating markers for location ${locationKey} with ${locationGroup.representatives.length} representatives`);
if (locationGroup.representatives.length === 1) {
// Single representative at this location
const rep = locationGroup.representatives[0];
const office = locationGroup.offices[0];
const marker = createOfficeMarker(rep, office);
if (marker) {
representativeMarkers.push(marker);
marker.addTo(representativesMap);
bounds.push([office.lat, office.lng]);
}
} else {
// Multiple representatives at same location - create offset markers
locationGroup.representatives.forEach((rep, repIndex) => {
const office = locationGroup.offices[repIndex];
// Add small offset to avoid exact overlap
const offsetDistance = 0.0005; // About 50 meters
const angle = (repIndex * 2 * Math.PI) / locationGroup.representatives.length;
const offsetLat = office.lat + (offsetDistance * Math.cos(angle));
const offsetLng = office.lng + (offsetDistance * Math.sin(angle));
const offsetOffice = {
...office,
lat: offsetLat,
lng: offsetLng
};
console.log(`Creating offset marker for ${rep.name} at ${offsetLat}, ${offsetLng}`);
const marker = createOfficeMarker(rep, offsetOffice, locationGroup.representatives.length > 1);
if (marker) {
representativeMarkers.push(marker);
marker.addTo(representativesMap);
bounds.push([offsetLat, offsetLng]);
}
});
}
});
console.log(`Total markers created: ${representativeMarkers.length}`);
console.log(`Bounds array:`, bounds);
// Fit map to show all offices, or center on Alberta if no offices found
if (bounds.length > 0) {
representativesMap.fitBounds(bounds, { padding: [20, 20] });
} else {
// If no office locations found, show a message and keep Alberta view
console.log('No office locations with coordinates found, showing message');
showMapMessage('Office locations not available for representatives in this area.');
}
console.log(`Displayed ${validOffices.length} office locations on map`);
}
// Extract office locations from representative data
function getOfficeLocations(representative) {
const offices = [];
console.log(`Getting office locations for ${representative.name}`);
console.log('Representative offices data:', representative.offices);
// Check various sources for office location data
if (representative.offices && Array.isArray(representative.offices)) {
representative.offices.forEach((office, index) => {
console.log(`Processing office ${index + 1}:`, office);
// Use the 'postal' field which contains the address
if (office.postal || office.address) {
const officeData = {
type: office.type || 'office',
address: office.postal || office.address || 'Office Address',
postal_code: office.postal_code,
phone: office.tel || office.phone,
fax: office.fax,
lat: office.lat,
lng: office.lng
};
console.log('Created office data:', officeData);
offices.push(officeData);
}
});
}
// For all offices without coordinates, add approximate coordinates
offices.forEach(office => {
if (!office.lat || !office.lng) {
console.log(`Adding coordinates to office for ${representative.name}`);
const approxLocation = getApproximateLocationByDistrict(representative.district_name, representative.representative_set_name);
console.log('Approximate location:', approxLocation);
if (approxLocation) {
office.lat = approxLocation.lat;
office.lng = approxLocation.lng;
console.log('Updated office with coordinates:', office);
}
}
});
// If no offices found at all, create a fallback office
if (offices.length === 0 && representative.representative_set_name) {
console.log(`No offices found, creating fallback office for ${representative.name}`);
const approxLocation = getApproximateLocationByDistrict(representative.district_name, representative.representative_set_name);
console.log('Approximate location:', approxLocation);
if (approxLocation) {
const fallbackOffice = {
type: 'representative',
address: `${representative.name} - ${representative.district_name || representative.representative_set_name}`,
lat: approxLocation.lat,
lng: approxLocation.lng
};
console.log('Created fallback office:', fallbackOffice);
offices.push(fallbackOffice);
}
}
console.log(`Total offices found for ${representative.name}:`, offices.length);
return offices;
}
// Get approximate location based on district and government level
function getApproximateLocationByDistrict(district, level) {
// Specific locations for Edmonton officials
const edmontonLocations = {
// City Hall for municipal officials
'Edmonton': { lat: 53.5444, lng: -113.4909 }, // Edmonton City Hall
"O-day'min": { lat: 53.5444, lng: -113.4909 }, // Edmonton City Hall
// Provincial Legislature
'Edmonton-Glenora': { lat: 53.5344, lng: -113.5065 }, // Alberta Legislature
// Federal offices (approximate downtown Edmonton)
'Edmonton Centre': { lat: 53.5461, lng: -113.4938 }
};
// Try specific district first
if (district && edmontonLocations[district]) {
return edmontonLocations[district];
}
// Fallback based on government level
const levelLocations = {
'House of Commons': { lat: 53.5461, lng: -113.4938 }, // Downtown Edmonton
'Legislative Assembly of Alberta': { lat: 53.5344, lng: -113.5065 }, // Alberta Legislature
'Edmonton City Council': { lat: 53.5444, lng: -113.4909 } // Edmonton City Hall
};
return levelLocations[level] || { lat: 53.9333, lng: -116.5765 }; // Default to Alberta center
}
// Create a marker for an office location
function createOfficeMarker(representative, office, isSharedLocation = false) {
if (!office.lat || !office.lng) {
return null;
}
// Determine icon based on government level
let icon = officeIcons.municipal; // default
if (representative.representative_set_name) {
if (representative.representative_set_name.includes('House of Commons')) {
icon = officeIcons.federal;
} else if (representative.representative_set_name.includes('Legislative Assembly')) {
icon = officeIcons.provincial;
}
}
const marker = L.marker([office.lat, office.lng], { icon });
// Create popup content
const popupContent = createOfficePopupContent(representative, office, isSharedLocation);
marker.bindPopup(popupContent, {
maxWidth: 300,
className: 'office-popup'
});
return marker;
}
// Create popup content for office markers
function createOfficePopupContent(representative, office, isSharedLocation = false) {
const level = getRepresentativeLevel(representative.representative_set_name);
const levelClass = level.toLowerCase().replace(' ', '-');
return `
<div class="office-popup-content">
<div class="rep-header ${levelClass}">
${representative.photo_url ? `<img src="${representative.photo_url}" alt="${representative.name}" class="rep-photo-small">` : ''}
<div class="rep-info">
<h4>${representative.name}</h4>
<p class="rep-level">${level}</p>
<p class="rep-district">${representative.district_name || 'District not specified'}</p>
${isSharedLocation ? '<p class="shared-location-note"><small><em>Note: Office location shared with other representatives</em></small></p>' : ''}
</div>
</div>
<div class="office-details">
<h5>Office Information</h5>
${office.address ? `<p><strong>Address:</strong> ${office.address}</p>` : ''}
${office.phone ? `<p><strong>Phone:</strong> <a href="tel:${office.phone}">${office.phone}</a></p>` : ''}
${office.fax ? `<p><strong>Fax:</strong> ${office.fax}</p>` : ''}
${office.postal_code ? `<p><strong>Postal Code:</strong> ${office.postal_code}</p>` : ''}
</div>
<div class="office-actions">
${representative.email ? `<button class="btn btn-primary btn-small email-btn" data-email="${representative.email}" data-name="${representative.name}" data-level="${representative.representative_set_name}">Send Email</button>` : ''}
</div>
</div>
`;
}// Get representative level for display
function getRepresentativeLevel(representativeSetName) {
if (!representativeSetName) return 'Representative';
if (representativeSetName.includes('House of Commons')) {
return 'Federal MP';
} else if (representativeSetName.includes('Legislative Assembly')) {
return 'Provincial MLA';
} else {
return 'Municipal Representative';
}
}
// Show a message on the map
function showMapMessage(message) {
const mapContainer = document.getElementById('main-map');
if (!mapContainer) return;
// Remove any existing message
const existingMessage = mapContainer.querySelector('.map-message');
if (existingMessage) {
existingMessage.remove();
}
// Create and show new message
const messageDiv = document.createElement('div');
messageDiv.className = 'map-message';
messageDiv.innerHTML = `
<div class="map-message-content">
<p>${message}</p>
</div>
`;
mapContainer.appendChild(messageDiv);
// Remove message after 5 seconds
setTimeout(() => {
if (messageDiv.parentNode) {
messageDiv.remove();
}
}, 5000);
}
// Initialize form handlers
function initializePostalForm() {
const postalForm = document.getElementById('postal-form');
// Handle postal code form submission
if (postalForm) {
postalForm.addEventListener('submit', (event) => {
event.preventDefault();
const postalCode = document.getElementById('postal-code').value.trim();
if (postalCode) {
handlePostalCodeSubmission(postalCode);
}
});
}
// Handle email button clicks in popups
document.addEventListener('click', (event) => {
if (event.target.classList.contains('email-btn')) {
event.preventDefault();
const email = event.target.dataset.email;
const name = event.target.dataset.name;
const level = event.target.dataset.level;
if (window.openEmailModal) {
window.openEmailModal(email, name, level);
}
}
});
}
// Handle postal code submission and fetch representatives
async function handlePostalCodeSubmission(postalCode) {
try {
showLoading();
hideError();
// Normalize postal code
const normalizedPostalCode = postalCode.toUpperCase().replace(/\s/g, '');
// Fetch representatives data
const response = await fetch(`/api/representatives/by-postal/${normalizedPostalCode}`);
const data = await response.json();
hideLoading();
if (data.success && data.data && data.data.representatives) {
// Display representatives on map
displayRepresentativeOffices(data.data.representatives, normalizedPostalCode);
// Also update the representatives display section using the existing system
if (window.representativesDisplay) {
window.representativesDisplay.displayRepresentatives(data.data.representatives);
}
// Update location info manually if the existing system doesn't work
const locationDetails = document.getElementById('location-details');
if (locationDetails && data.data.location) {
const location = data.data.location;
locationDetails.textContent = `${location.city}, ${location.province} (${normalizedPostalCode})`;
} else if (locationDetails) {
locationDetails.textContent = `Postal Code: ${normalizedPostalCode}`;
}
if (window.locationInfo) {
window.locationInfo.updateLocationInfo(data.data.location, normalizedPostalCode);
}
// Show the representatives section
const representativesSection = document.getElementById('representatives-section');
representativesSection.style.display = 'block';
// Fix map rendering after section becomes visible
setTimeout(() => {
if (!representativesMap) {
initializeRepresentativesMap();
}
if (representativesMap) {
representativesMap.invalidateSize();
// Try to fit bounds again if we have markers
if (representativeMarkers.length > 0) {
const bounds = representativeMarkers.map(marker => marker.getLatLng());
if (bounds.length > 0) {
representativesMap.fitBounds(bounds, { padding: [20, 20] });
}
}
}
}, 300);
// Show refresh button
const refreshBtn = document.getElementById('refresh-btn');
if (refreshBtn) {
refreshBtn.style.display = 'inline-block';
// Store postal code for refresh functionality
refreshBtn.dataset.postalCode = normalizedPostalCode;
}
// Show success message
if (window.messageDisplay) {
window.messageDisplay.show(`Found ${data.data.representatives.length} representatives for ${normalizedPostalCode}`, 'success', 3000);
}
} else {
showError(data.message || 'Unable to find representatives for this postal code.');
}
} catch (error) {
hideLoading();
console.error('Error fetching representatives:', error);
showError('An error occurred while looking up representatives. Please try again.');
}
}
// Utility functions for loading and error states
function showLoading() {
const loading = document.getElementById('loading');
if (loading) loading.style.display = 'block';
}
function hideLoading() {
const loading = document.getElementById('loading');
if (loading) loading.style.display = 'none';
}
function showError(message) {
const errorDiv = document.getElementById('error-message');
if (errorDiv) {
errorDiv.textContent = message;
errorDiv.style.display = 'block';
}
}
function hideError() {
const errorDiv = document.getElementById('error-message');
if (errorDiv) {
errorDiv.style.display = 'none';
}
}
// Initialize form handlers
function initializePostalForm() {
const postalForm = document.getElementById('postal-form');
const refreshBtn = document.getElementById('refresh-btn');
// Handle postal code form submission
if (postalForm) {
postalForm.addEventListener('submit', (event) => {
event.preventDefault();
const postalCode = document.getElementById('postal-code').value.trim();
if (postalCode) {
handlePostalCodeSubmission(postalCode);
}
});
}
// Handle refresh button
if (refreshBtn) {
refreshBtn.addEventListener('click', () => {
const postalCode = refreshBtn.dataset.postalCode || document.getElementById('postal-code').value.trim();
if (postalCode) {
handlePostalCodeSubmission(postalCode);
}
});
}
}
// Initialize everything when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
initializeRepresentativesMap();
initializePostalForm();
});
// Global function for opening email modal from map popups
window.openEmailModal = function(email, name, level) {
if (window.emailComposer) {
window.emailComposer.openModal({
email: email,
name: name,
level: level
}, currentPostalCode);
}
};
// Export functions for use by other modules
window.RepresentativesMap = {
displayRepresentativeOffices,
initializeRepresentativesMap,
clearRepresentativeMarkers,
handlePostalCodeSubmission
};

View File

@ -0,0 +1,230 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Terms of Use - BNKops Influence Campaign Tool</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="css/styles.css">
<style>
.terms-container {
max-width: 800px;
margin: 0 auto;
padding: 2rem 1rem;
line-height: 1.6;
}
.terms-header {
text-align: center;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 2px solid #e9ecef;
}
.terms-content h2 {
color: #2c3e50;
margin-top: 2rem;
margin-bottom: 1rem;
}
.terms-content h3 {
color: #34495e;
margin-top: 1.5rem;
margin-bottom: 0.75rem;
}
.terms-content p {
margin-bottom: 1rem;
text-align: justify;
}
.terms-content ul {
margin-bottom: 1rem;
padding-left: 2rem;
}
.terms-content li {
margin-bottom: 0.5rem;
}
.highlight-box {
background: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 8px;
padding: 1rem;
margin: 1.5rem 0;
}
.contact-info {
background: #f8f9fa;
border-left: 4px solid #3498db;
padding: 1rem;
margin: 1.5rem 0;
}
.back-link {
display: inline-block;
margin-bottom: 1rem;
color: #3498db;
text-decoration: none;
}
.back-link:hover {
text-decoration: underline;
}
.last-updated {
font-style: italic;
color: #666;
text-align: center;
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid #e9ecef;
}
</style>
</head>
<body>
<div class="terms-container">
<a href="javascript:history.back()" class="back-link">← Back</a>
<header class="terms-header">
<h1>Terms of Use & Privacy Notice</h1>
<p>BNKops Influence Campaign Tool</p>
</header>
<div class="terms-content">
<div class="highlight-box">
<strong>Important Notice:</strong> 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.
</div>
<h2>1. Acceptance of Terms</h2>
<p>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.</p>
<h2>2. Description of Service</h2>
<p>The BNKops Influence Campaign Tool is a web-based platform that enables users to:</p>
<ul>
<li>Find their elected representatives at federal, provincial, and municipal levels</li>
<li>Access contact information for these representatives</li>
<li>Compose and send emails to their representatives</li>
<li>Participate in organized advocacy campaigns</li>
<li>View office locations and contact details</li>
</ul>
<h2>3. Data Collection and Privacy</h2>
<h3>3.1 Information We Collect</h3>
<p>We collect and store the following information:</p>
<ul>
<li><strong>Contact Information:</strong> Name, email address, and postal code when you use our email services</li>
<li><strong>Communication Content:</strong> Email messages you compose and send through our platform</li>
<li><strong>Usage Data:</strong> Information about how you interact with our service, including pages visited, features used, and timestamps</li>
<li><strong>Technical Data:</strong> IP address, browser type, device information, and other technical identifiers</li>
<li><strong>Representative Data:</strong> Information about elected officials obtained from public APIs and sources</li>
</ul>
<h3>3.2 How We Use Your Information</h3>
<p>Your information is used to:</p>
<ul>
<li>Facilitate communication between you and your elected representatives</li>
<li>Maintain records of campaign participation and email sending activities</li>
<li>Improve our service and user experience</li>
<li>Comply with legal obligations and respond to legal requests</li>
<li>Send you communications about campaigns, service updates, or related democratic engagement opportunities</li>
</ul>
<h3>3.3 Data Retention</h3>
<p>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.</p>
<h2>4. Communications from BNKops</h2>
<div class="highlight-box">
<strong>Notice:</strong> By using this service, you consent to receiving communications from BNKops regarding:
<ul>
<li>Service updates and improvements</li>
<li>New campaign opportunities</li>
<li>Democratic engagement initiatives</li>
<li>Technical notifications and security updates</li>
</ul>
<p>You may opt out of non-essential communications by contacting us using the information provided below.</p>
</div>
<h2>5. User Responsibilities</h2>
<p>As a user of this service, you agree to:</p>
<ul>
<li>Provide accurate and truthful information</li>
<li>Use the service only for legitimate democratic engagement purposes</li>
<li>Respect the time and resources of elected representatives</li>
<li>Not use the service for spam, harassment, or illegal activities</li>
<li>Not attempt to compromise the security or functionality of the service</li>
<li>Comply with all applicable Canadian federal, provincial, and municipal laws</li>
</ul>
<h2>6. Prohibited Uses</h2>
<p>You may not use this service to:</p>
<ul>
<li>Send threatening, abusive, or harassing communications</li>
<li>Distribute spam or unsolicited commercial content</li>
<li>Impersonate another person or provide false identity information</li>
<li>Attempt to gain unauthorized access to other users' data</li>
<li>Use automated tools to send bulk communications without authorization</li>
<li>Violate any applicable laws or regulations</li>
</ul>
<h2>7. Third-Party Services</h2>
<p>Our service integrates with third-party services including:</p>
<ul>
<li><strong>Represent API (Open North):</strong> For obtaining representative information</li>
<li><strong>Email Service Providers:</strong> For sending communications</li>
<li><strong>Database Services:</strong> For data storage and management</li>
</ul>
<p>These third parties have their own privacy policies and terms of service, which govern their collection and use of your information.</p>
<h2>8. Limitation of Liability</h2>
<p>BNKops provides this service "as is" without warranties of any kind. We are not responsible for:</p>
<ul>
<li>The accuracy or completeness of representative contact information</li>
<li>The delivery or response to communications sent through our platform</li>
<li>Any actions taken by elected representatives based on communications sent</li>
<li>Service interruptions or technical issues</li>
<li>Any damages resulting from your use of the service</li>
</ul>
<h2>9. Privacy Rights (Canadian Law)</h2>
<p>Under Canadian privacy law, you have the right to:</p>
<ul>
<li>Access your personal information held by us</li>
<li>Request correction of inaccurate information</li>
<li>Request deletion of your personal information (subject to legal retention requirements)</li>
<li>Withdraw consent for certain uses of your information</li>
<li>File a complaint with the Privacy Commissioner of Canada</li>
</ul>
<h2>10. Changes to Terms</h2>
<p>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.</p>
<h2>11. Governing Law</h2>
<p>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.</p>
<h2>12. Contact Information</h2>
<div class="contact-info">
<strong>BNKops</strong><br>
For questions about these terms, privacy concerns, or to exercise your rights:<br>
<strong>Website:</strong> <a href="https://bnkops.com" target="_blank">https://bnkops.com</a><br>
<strong>Email:</strong> privacy@bnkops.com<br>
<br>
For technical support or service-related inquiries, please contact us through our website.
</div>
<h2>13. Severability</h2>
<p>If any provision of these terms is found to be unenforceable, the remaining provisions will continue in full force and effect.</p>
<div class="last-updated">
Last updated: September 23, 2025
</div>
</div>
<footer style="text-align: center; margin-top: 2rem; padding-top: 1rem; border-top: 1px solid #e9ecef;">
<p>&copy; 2025 <a href="https://bnkops.com/" target="_blank">BNKops</a>. All rights reserved.</p>
<p><a href="index.html">Return to Main Application</a></p>
</footer>
</div>
</body>
</html>

View File

@ -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,

View File

@ -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"],
},
},
}));

View File

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

View File

@ -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
};

View File

@ -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()
};
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
};
@ -362,6 +457,7 @@ class NocoDBService {
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.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
};
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 };
}
}

View File

@ -78,15 +78,15 @@
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">{{APP_NAME}}</div>
<p style="margin: 5px 0 0 0; color: #6c757d;">Constituent Communication Platform</p>
</div>
<div class="content">
<div class="message-body">
<p>Dear {{RECIPIENT_NAME}},</p>
{{MESSAGE}}
<p>Sincerely,<br>
{{SENDER_NAME}}<br>
{{POSTAL_CODE}}</p>
</div>
<div class="sender-info">

View File

@ -1,7 +1,11 @@
Message from Constituent - {{APP_NAME}}
Dear {{RECIPIENT_NAME}},
{{MESSAGE}}
Sincerely,
{{SENDER_NAME}}
{{POSTAL_CODE}}
---
Constituent Information:
Name: {{SENDER_NAME}}

View File

@ -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 = {

View File

@ -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

View File

@ -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

View File

@ -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 => `<p>${para}</p>`).join('')}
</div>
<div class="footer">
This email was sent via the Alberta Influence Campaign platform.<br>
This email was sent via the BNKops Influence Campaign platform.<br>
The constituent's email address is: ${from_email}
</div>
</body>

View File

@ -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

View File

@ -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
fi