Bunch of buag fixes and updates
This commit is contained in:
parent
a26d9b8d78
commit
d29ffa6300
@ -150,7 +150,7 @@ SMTP_SECURE=false
|
|||||||
SMTP_USER=test
|
SMTP_USER=test
|
||||||
SMTP_PASS=test
|
SMTP_PASS=test
|
||||||
SMTP_FROM_EMAIL=dev@albertainfluence.local
|
SMTP_FROM_EMAIL=dev@albertainfluence.local
|
||||||
SMTP_FROM_NAME="Alberta Influence Campaign (DEV)"
|
SMTP_FROM_NAME="BNKops Influence Campaign (DEV)"
|
||||||
|
|
||||||
# Email Testing
|
# Email Testing
|
||||||
TEST_EMAIL_RECIPIENT=developer@example.com
|
TEST_EMAIL_RECIPIENT=developer@example.com
|
||||||
@ -182,7 +182,7 @@ SMTP_PORT=587
|
|||||||
SMTP_SECURE=false
|
SMTP_SECURE=false
|
||||||
SMTP_USER=your_email@gmail.com
|
SMTP_USER=your_email@gmail.com
|
||||||
SMTP_PASS=your_app_password
|
SMTP_PASS=your_app_password
|
||||||
SMTP_FROM_NAME=Alberta Influence Campaign
|
SMTP_FROM_NAME=BNKops Influence Campaign
|
||||||
SMTP_FROM_EMAIL=your_email@gmail.com
|
SMTP_FROM_EMAIL=your_email@gmail.com
|
||||||
|
|
||||||
# Rate Limiting
|
# Rate Limiting
|
||||||
|
|||||||
@ -50,10 +50,23 @@ class AuthController {
|
|||||||
|
|
||||||
// Update last login time
|
// Update last login time
|
||||||
try {
|
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, {
|
await nocodbService.updateUser(userId, {
|
||||||
'Last Login': new Date().toISOString()
|
'Last Login': new Date().toISOString()
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
console.warn('No valid user ID found for updating last login time');
|
||||||
|
}
|
||||||
} catch (updateError) {
|
} catch (updateError) {
|
||||||
console.warn('Failed to update last login time:', updateError.message);
|
console.warn('Failed to update last login time:', updateError.message);
|
||||||
// Don't fail the login
|
// Don't fail the login
|
||||||
@ -61,7 +74,7 @@ class AuthController {
|
|||||||
|
|
||||||
// Set session
|
// Set session
|
||||||
req.session.authenticated = true;
|
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.userEmail = user.Email || user.email;
|
||||||
req.session.userName = user.Name || user.name;
|
req.session.userName = user.Name || user.name;
|
||||||
req.session.isAdmin = user.Admin || user.admin || false;
|
req.session.isAdmin = user.Admin || user.admin || false;
|
||||||
|
|||||||
@ -3,6 +3,30 @@ const emailService = require('../services/email');
|
|||||||
const representAPI = require('../services/represent-api');
|
const representAPI = require('../services/represent-api');
|
||||||
const { generateSlug, validateSlug } = require('../utils/validators');
|
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 {
|
class CampaignsController {
|
||||||
// Get all campaigns (for admin panel)
|
// Get all campaigns (for admin panel)
|
||||||
async getAllCampaigns(req, res, next) {
|
async getAllCampaigns(req, res, next) {
|
||||||
@ -31,6 +55,7 @@ class CampaignsController {
|
|||||||
allow_mailto_link: campaign['Allow Mailto Link'] || campaign.allow_mailto_link,
|
allow_mailto_link: campaign['Allow Mailto Link'] || campaign.allow_mailto_link,
|
||||||
collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info,
|
collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info,
|
||||||
show_email_count: campaign['Show Email Count'] || campaign.show_email_count,
|
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,
|
target_government_levels: campaign['Target Government Levels'] || campaign.target_government_levels,
|
||||||
created_at: campaign.CreatedAt || campaign.created_at,
|
created_at: campaign.CreatedAt || campaign.created_at,
|
||||||
updated_at: campaign.UpdatedAt || campaign.updated_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,
|
allow_mailto_link: campaign['Allow Mailto Link'] || campaign.allow_mailto_link,
|
||||||
collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info,
|
collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info,
|
||||||
show_email_count: campaign['Show Email Count'] || campaign.show_email_count,
|
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,
|
target_government_levels: campaign['Target Government Levels'] || campaign.target_government_levels,
|
||||||
created_at: campaign.CreatedAt || campaign.created_at,
|
created_at: campaign.CreatedAt || campaign.created_at,
|
||||||
updated_at: campaign.UpdatedAt || campaign.updated_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,
|
allow_mailto_link: campaign['Allow Mailto Link'] || campaign.allow_mailto_link,
|
||||||
collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info,
|
collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info,
|
||||||
show_email_count: campaign['Show Email Count'] || campaign.show_email_count,
|
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)
|
target_government_levels: Array.isArray(campaign['Target Government Levels'] || campaign.target_government_levels)
|
||||||
? (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
|
: (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_subject,
|
||||||
email_body,
|
email_body,
|
||||||
call_to_action,
|
call_to_action,
|
||||||
|
status = 'draft',
|
||||||
allow_smtp_email = true,
|
allow_smtp_email = true,
|
||||||
allow_mailto_link = true,
|
allow_mailto_link = true,
|
||||||
collect_user_info = true,
|
collect_user_info = true,
|
||||||
show_email_count = true,
|
show_email_count = true,
|
||||||
|
allow_email_editing = false,
|
||||||
target_government_levels = ['Federal', 'Provincial', 'Municipal']
|
target_government_levels = ['Federal', 'Provincial', 'Municipal']
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
@ -206,11 +235,12 @@ class CampaignsController {
|
|||||||
email_subject,
|
email_subject,
|
||||||
email_body,
|
email_body,
|
||||||
call_to_action,
|
call_to_action,
|
||||||
status: 'draft',
|
status,
|
||||||
allow_smtp_email,
|
allow_smtp_email,
|
||||||
allow_mailto_link,
|
allow_mailto_link,
|
||||||
collect_user_info,
|
collect_user_info,
|
||||||
show_email_count,
|
show_email_count,
|
||||||
|
allow_email_editing,
|
||||||
// NocoDB MultiSelect expects an array of values
|
// NocoDB MultiSelect expects an array of values
|
||||||
target_government_levels: Array.isArray(target_government_levels)
|
target_government_levels: Array.isArray(target_government_levels)
|
||||||
? target_government_levels
|
? target_government_levels
|
||||||
@ -237,6 +267,7 @@ class CampaignsController {
|
|||||||
allow_mailto_link: campaign['Allow Mailto Link'] || campaign.allow_mailto_link,
|
allow_mailto_link: campaign['Allow Mailto Link'] || campaign.allow_mailto_link,
|
||||||
collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info,
|
collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info,
|
||||||
show_email_count: campaign['Show Email Count'] || campaign.show_email_count,
|
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,
|
target_government_levels: campaign['Target Government Levels'] || campaign.target_government_levels,
|
||||||
created_at: campaign.CreatedAt || campaign.created_at,
|
created_at: campaign.CreatedAt || campaign.created_at,
|
||||||
updated_at: campaign.UpdatedAt || campaign.updated_at
|
updated_at: campaign.UpdatedAt || campaign.updated_at
|
||||||
@ -356,7 +387,9 @@ class CampaignsController {
|
|||||||
recipientName,
|
recipientName,
|
||||||
recipientTitle,
|
recipientTitle,
|
||||||
recipientLevel,
|
recipientLevel,
|
||||||
emailMethod = 'smtp'
|
emailMethod = 'smtp',
|
||||||
|
customEmailSubject,
|
||||||
|
customEmailBody
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
// Get campaign
|
// Get campaign
|
||||||
@ -392,8 +425,14 @@ class CampaignsController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const subject = campaign['Email Subject'] || campaign.email_subject;
|
// Use custom email content if provided (when email editing is enabled), otherwise use campaign defaults
|
||||||
const message = campaign['Email Body'] || campaign.email_body;
|
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 };
|
let emailResult = { success: true };
|
||||||
|
|
||||||
@ -539,12 +578,31 @@ class CampaignsController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get representatives
|
// First check cache for representatives
|
||||||
const result = await representAPI.getRepresentativesByPostalCode(postalCode);
|
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
|
// Process representatives from both concordance and centroid
|
||||||
let representatives = [];
|
|
||||||
|
|
||||||
// Add concordance representatives (if any)
|
// Add concordance representatives (if any)
|
||||||
if (result.representatives_concordance && result.representatives_concordance.length > 0) {
|
if (result.representatives_concordance && result.representatives_concordance.length > 0) {
|
||||||
representatives = representatives.concat(result.representatives_concordance);
|
representatives = representatives.concat(result.representatives_concordance);
|
||||||
@ -555,14 +613,21 @@ class CampaignsController {
|
|||||||
representatives = representatives.concat(result.representatives_centroid);
|
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) {
|
if (representatives.length === 0) {
|
||||||
return res.json({
|
return res.json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'No representatives found for this postal code',
|
message: 'No representatives found for this postal code',
|
||||||
representatives: [],
|
representatives: [],
|
||||||
location: {
|
location: {
|
||||||
city: result.city,
|
city: result?.city || 'Alberta',
|
||||||
province: result.province
|
province: result?.province || 'AB'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -602,8 +667,8 @@ class CampaignsController {
|
|||||||
success: true,
|
success: true,
|
||||||
representatives: filteredRepresentatives,
|
representatives: filteredRepresentatives,
|
||||||
location: {
|
location: {
|
||||||
city: result.city,
|
city: result?.city || cachedData[0]?.city || 'Alberta',
|
||||||
province: result.province
|
province: result?.province || cachedData[0]?.province || 'AB'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@ const nocoDB = require('../services/nocodb');
|
|||||||
class EmailsController {
|
class EmailsController {
|
||||||
async sendEmail(req, res, next) {
|
async sendEmail(req, res, next) {
|
||||||
try {
|
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
|
// Send the email using template system
|
||||||
const emailResult = await emailService.sendRepresentativeEmail(
|
const emailResult = await emailService.sendRepresentativeEmail(
|
||||||
@ -13,7 +13,8 @@ class EmailsController {
|
|||||||
senderEmail,
|
senderEmail,
|
||||||
subject,
|
subject,
|
||||||
message,
|
message,
|
||||||
postalCode
|
postalCode,
|
||||||
|
recipientName
|
||||||
);
|
);
|
||||||
|
|
||||||
// Log the email send event
|
// Log the email send event
|
||||||
@ -22,6 +23,7 @@ class EmailsController {
|
|||||||
senderName,
|
senderName,
|
||||||
senderEmail,
|
senderEmail,
|
||||||
subject,
|
subject,
|
||||||
|
message,
|
||||||
postalCode,
|
postalCode,
|
||||||
status: emailResult.success ? 'sent' : 'failed',
|
status: emailResult.success ? 'sent' : 'failed',
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
@ -53,13 +55,14 @@ class EmailsController {
|
|||||||
|
|
||||||
async previewEmail(req, res, next) {
|
async previewEmail(req, res, next) {
|
||||||
try {
|
try {
|
||||||
const { recipientEmail, subject, message, senderName, senderEmail, postalCode } = req.body;
|
const { recipientEmail, subject, message, senderName, senderEmail, postalCode, recipientName } = req.body;
|
||||||
|
|
||||||
const templateVariables = {
|
const templateVariables = {
|
||||||
MESSAGE: message,
|
MESSAGE: message,
|
||||||
SENDER_NAME: senderName || 'Anonymous',
|
SENDER_NAME: senderName || 'Anonymous',
|
||||||
SENDER_EMAIL: senderEmail || 'unknown@example.com',
|
SENDER_EMAIL: senderEmail || 'unknown@example.com',
|
||||||
POSTAL_CODE: postalCode || 'Unknown'
|
POSTAL_CODE: postalCode || 'Unknown',
|
||||||
|
RECIPIENT_NAME: recipientName || 'Representative'
|
||||||
};
|
};
|
||||||
|
|
||||||
const emailOptions = {
|
const emailOptions = {
|
||||||
@ -74,6 +77,23 @@ class EmailsController {
|
|||||||
|
|
||||||
const preview = await emailService.previewTemplatedEmail('representative-contact', templateVariables, emailOptions);
|
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({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
preview: preview,
|
preview: preview,
|
||||||
|
|||||||
@ -12,12 +12,16 @@ async function cacheRepresentatives(postalCode, representatives, representData)
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Cache representatives using the existing method
|
// 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) {
|
} catch (error) {
|
||||||
console.log(`Failed to cache representatives for ${postalCode}:`, error.message);
|
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) {
|
if (cachedData && cachedData.length > 0) {
|
||||||
return res.json({
|
return res.json({
|
||||||
source: 'cache',
|
success: true,
|
||||||
|
source: 'Local Cache',
|
||||||
|
data: {
|
||||||
postalCode: formattedPostalCode,
|
postalCode: formattedPostalCode,
|
||||||
|
location: {
|
||||||
|
city: cachedData[0]?.city || 'Alberta',
|
||||||
|
province: 'AB'
|
||||||
|
},
|
||||||
representatives: cachedData
|
representatives: cachedData
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (cacheError) {
|
} catch (cacheError) {
|
||||||
@ -86,6 +97,9 @@ class RepresentativesController {
|
|||||||
representatives = representatives.concat(representData.representatives_centroid);
|
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 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(`Representatives centroid count: ${representData.representatives_centroid ? representData.representatives_centroid.length : 0}`);
|
||||||
console.log(`Total representatives found: ${representatives.length}`);
|
console.log(`Total representatives found: ${representatives.length}`);
|
||||||
@ -111,6 +125,7 @@ class RepresentativesController {
|
|||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
source: 'Open North',
|
||||||
data: {
|
data: {
|
||||||
postalCode,
|
postalCode,
|
||||||
location: {
|
location: {
|
||||||
@ -151,7 +166,7 @@ class RepresentativesController {
|
|||||||
await nocoDB.storeRepresentatives(formattedPostalCode, representData.representatives_concordance);
|
await nocoDB.storeRepresentatives(formattedPostalCode, representData.representatives_concordance);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
source: 'refreshed',
|
source: 'Open North',
|
||||||
postalCode: formattedPostalCode,
|
postalCode: formattedPostalCode,
|
||||||
representatives: representData.representatives_concordance,
|
representatives: representData.representatives_concordance,
|
||||||
city: representData.city,
|
city: representData.city,
|
||||||
|
|||||||
@ -588,6 +588,10 @@ Sincerely,
|
|||||||
<input type="checkbox" id="create-show-count" name="show_email_count" checked>
|
<input type="checkbox" id="create-show-count" name="show_email_count" checked>
|
||||||
<label for="create-show-count">📊 Show Email Count</label>
|
<label for="create-show-count">📊 Show Email Count</label>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -681,6 +685,10 @@ Sincerely,
|
|||||||
<input type="checkbox" id="edit-show-count" name="show_email_count">
|
<input type="checkbox" id="edit-show-count" name="show_email_count">
|
||||||
<label for="edit-show-count">Show Email Count</label>
|
<label for="edit-show-count">Show Email Count</label>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -83,6 +83,43 @@
|
|||||||
white-space: pre-wrap;
|
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 {
|
.representatives-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@ -249,7 +286,7 @@
|
|||||||
<div class="progress-steps">
|
<div class="progress-steps">
|
||||||
<div class="step active" id="step-info">Enter Your Info</div>
|
<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-postal">Find Representatives</div>
|
||||||
<div class="step" id="step-send">Send Emails</div>
|
<div class="step" id="step-send">Send Messages</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- User Information Form -->
|
<!-- User Information Form -->
|
||||||
@ -281,11 +318,22 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Email Preview -->
|
<!-- 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>
|
<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-subject" id="preview-subject"></div>
|
||||||
<div class="email-body" id="preview-body"></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>
|
</div>
|
||||||
|
|
||||||
<!-- Representatives Section -->
|
<!-- Representatives Section -->
|
||||||
@ -321,6 +369,11 @@
|
|||||||
<div id="error-message" class="error-message" style="display: none;"></div>
|
<div id="error-message" class="error-message" style="display: none;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<footer style="text-align: center; margin-top: 2rem; padding: 1rem; border-top: 1px solid #e9ecef;">
|
||||||
|
<p>© 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>
|
<script src="/js/campaign.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@ -370,6 +370,8 @@ header p {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
padding: 20px;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
@ -434,6 +436,175 @@ header p {
|
|||||||
margin-top: 5px;
|
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 */
|
||||||
.message-display {
|
.message-display {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@ -476,6 +647,23 @@ footer a:hover {
|
|||||||
text-decoration: underline;
|
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 */
|
/* Responsive Design */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.container {
|
.container {
|
||||||
@ -515,21 +703,365 @@ footer a:hover {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
gap: 15px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.rep-photo {
|
.rep-photo {
|
||||||
width: 100px;
|
margin-bottom: 15px;
|
||||||
height: 100px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.rep-card .rep-actions {
|
.rep-card .rep-actions {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.rep-card .rep-actions .btn {
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 12px 20px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
margin: 4px 0;
|
||||||
text-align: center;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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">
|
<link rel="stylesheet" href="css/styles.css">
|
||||||
<style>
|
<style>
|
||||||
.test-container {
|
.test-container {
|
||||||
|
|||||||
@ -5,22 +5,33 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>BNKops Influence Campaign Tool</title>
|
<title>BNKops Influence Campaign Tool</title>
|
||||||
<link rel="icon" href="data:,">
|
<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">
|
<link rel="stylesheet" href="css/styles.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<header>
|
<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>
|
<p>Connect with your elected representatives across all levels of government</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<!-- Postal Code Lookup Section -->
|
<!-- Postal Code Input Section -->
|
||||||
<section id="postal-lookup">
|
<section id="postal-input-section">
|
||||||
|
<div class="map-header">
|
||||||
<h2>Find Your Representatives</h2>
|
<h2>Find Your Representatives</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Postal Code Input -->
|
||||||
|
<div class="postal-input-section">
|
||||||
<form id="postal-form">
|
<form id="postal-form">
|
||||||
<div class="form-group">
|
<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">
|
<div class="input-group">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@ -43,6 +54,7 @@
|
|||||||
<div class="spinner"></div>
|
<div class="spinner"></div>
|
||||||
<p>Looking up your representatives...</p>
|
<p>Looking up your representatives...</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Representatives Display Section -->
|
<!-- Representatives Display Section -->
|
||||||
@ -55,6 +67,15 @@
|
|||||||
<div id="representatives-container">
|
<div id="representatives-container">
|
||||||
<!-- Representatives will be dynamically inserted here -->
|
<!-- Representatives will be dynamically inserted here -->
|
||||||
</div>
|
</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>
|
</section>
|
||||||
|
|
||||||
<!-- Email Compose Modal -->
|
<!-- Email Compose Modal -->
|
||||||
@ -104,27 +125,82 @@
|
|||||||
|
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="button" id="cancel-email" class="btn btn-secondary">Cancel</button>
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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">×</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 -->
|
<!-- Success/Error Messages -->
|
||||||
<div id="message-display" class="message-display" style="display: none;"></div>
|
<div id="message-display" class="message-display" style="display: none;"></div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<p>© 2025 <a href="https://bnkops.com/" target="_blank">BNKops</a> Influence Campaign Tool. Connect with democracy.</p>
|
<p>© 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>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>
|
</footer>
|
||||||
</div>
|
</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/api-client.js"></script>
|
||||||
<script src="js/postal-lookup.js"></script>
|
<script src="js/postal-lookup.js"></script>
|
||||||
<script src="js/representatives-display.js"></script>
|
<script src="js/representatives-display.js"></script>
|
||||||
<script src="js/email-composer.js"></script>
|
<script src="js/email-composer.js"></script>
|
||||||
|
<script src="js/representatives-map.js"></script>
|
||||||
<script src="js/main.js"></script>
|
<script src="js/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@ -251,6 +251,7 @@ class AdminPanel {
|
|||||||
email_subject: formData.get('email_subject'),
|
email_subject: formData.get('email_subject'),
|
||||||
email_body: formData.get('email_body'),
|
email_body: formData.get('email_body'),
|
||||||
call_to_action: formData.get('call_to_action'),
|
call_to_action: formData.get('call_to_action'),
|
||||||
|
status: formData.get('status'),
|
||||||
allow_smtp_email: formData.get('allow_smtp_email') === 'on',
|
allow_smtp_email: formData.get('allow_smtp_email') === 'on',
|
||||||
allow_mailto_link: formData.get('allow_mailto_link') === 'on',
|
allow_mailto_link: formData.get('allow_mailto_link') === 'on',
|
||||||
collect_user_info: formData.get('collect_user_info') === '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="allow_mailto_link"]').checked = campaign.allow_mailto_link;
|
||||||
form.querySelector('[name="collect_user_info"]').checked = campaign.collect_user_info;
|
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="show_email_count"]').checked = campaign.show_email_count;
|
||||||
|
form.querySelector('[name="allow_email_editing"]').checked = campaign.allow_email_editing;
|
||||||
|
|
||||||
// Government levels
|
// Government levels
|
||||||
const targetLevels = campaign.target_government_levels ?
|
const targetLevels = campaign.target_government_levels ?
|
||||||
|
|||||||
@ -71,6 +71,11 @@ class APIClient {
|
|||||||
async sendEmail(emailData) {
|
async sendEmail(emailData) {
|
||||||
return this.post('/emails/send', emailData);
|
return this.post('/emails/send', emailData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Preview email before sending
|
||||||
|
async previewEmail(emailData) {
|
||||||
|
return this.post('/emails/preview', emailData);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create global instance
|
// Create global instance
|
||||||
|
|||||||
@ -66,10 +66,8 @@ class CampaignPage {
|
|||||||
document.getElementById('call-to-action').style.display = 'block';
|
document.getElementById('call-to-action').style.display = 'block';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show email preview
|
// Set up email preview
|
||||||
document.getElementById('preview-subject').textContent = this.campaign.email_subject;
|
this.setupEmailPreview();
|
||||||
document.getElementById('preview-body').textContent = this.campaign.email_body;
|
|
||||||
document.getElementById('email-preview').style.display = 'block';
|
|
||||||
|
|
||||||
// Set up email method options
|
// Set up email method options
|
||||||
this.setupEmailMethodOptions();
|
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) {
|
formatPostalCode(e) {
|
||||||
let value = e.target.value.replace(/\s/g, '').toUpperCase();
|
let value = e.target.value.replace(/\s/g, '').toUpperCase();
|
||||||
if (value.length > 3) {
|
if (value.length > 3) {
|
||||||
@ -364,8 +471,8 @@ class CampaignPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
openMailtoLink(recipientEmail) {
|
openMailtoLink(recipientEmail) {
|
||||||
const subject = encodeURIComponent(this.campaign.email_subject);
|
const subject = encodeURIComponent(this.currentEmailSubject || this.campaign.email_subject);
|
||||||
const body = encodeURIComponent(this.campaign.email_body);
|
const body = encodeURIComponent(this.currentEmailBody || this.campaign.email_body);
|
||||||
const mailtoUrl = `mailto:${recipientEmail}?subject=${subject}&body=${body}`;
|
const mailtoUrl = `mailto:${recipientEmail}?subject=${subject}&body=${body}`;
|
||||||
|
|
||||||
// Track the mailto click
|
// Track the mailto click
|
||||||
@ -378,12 +485,7 @@ class CampaignPage {
|
|||||||
this.showLoading('Sending email...');
|
this.showLoading('Sending email...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/campaigns/${this.campaignSlug}/send-email`, {
|
const emailData = {
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
userEmail: this.userInfo.userEmail,
|
userEmail: this.userInfo.userEmail,
|
||||||
userName: this.userInfo.userName,
|
userName: this.userInfo.userName,
|
||||||
postalCode: this.userInfo.postalCode,
|
postalCode: this.userInfo.postalCode,
|
||||||
@ -392,7 +494,20 @@ class CampaignPage {
|
|||||||
recipientTitle,
|
recipientTitle,
|
||||||
recipientLevel,
|
recipientLevel,
|
||||||
emailMethod: 'smtp'
|
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();
|
const data = await response.json();
|
||||||
|
|||||||
@ -2,35 +2,68 @@
|
|||||||
class EmailComposer {
|
class EmailComposer {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.modal = document.getElementById('email-modal');
|
this.modal = document.getElementById('email-modal');
|
||||||
|
this.previewModal = document.getElementById('email-preview-modal');
|
||||||
this.form = document.getElementById('email-form');
|
this.form = document.getElementById('email-form');
|
||||||
this.closeBtn = document.getElementById('close-modal');
|
this.closeBtn = document.getElementById('close-modal');
|
||||||
|
this.closePreviewBtn = document.getElementById('close-preview-modal');
|
||||||
this.cancelBtn = document.getElementById('cancel-email');
|
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.messageTextarea = document.getElementById('email-message');
|
||||||
this.charCounter = document.querySelector('.char-counter');
|
this.charCounter = document.querySelector('.char-counter');
|
||||||
|
|
||||||
this.currentRecipient = null;
|
this.currentRecipient = null;
|
||||||
|
this.currentEmailData = null;
|
||||||
|
this.lastPreviewTime = 0; // Track last preview request time
|
||||||
this.init();
|
this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
// Modal controls
|
// Modal controls
|
||||||
this.closeBtn.addEventListener('click', () => this.closeModal());
|
this.closeBtn.addEventListener('click', () => this.closeModal());
|
||||||
|
this.closePreviewBtn.addEventListener('click', () => this.closePreviewModal());
|
||||||
this.cancelBtn.addEventListener('click', () => this.closeModal());
|
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) => {
|
this.modal.addEventListener('click', (e) => {
|
||||||
if (e.target === this.modal) this.closeModal();
|
if (e.target === this.modal) this.closeModal();
|
||||||
});
|
});
|
||||||
|
this.previewModal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === this.previewModal) this.closePreviewModal();
|
||||||
|
});
|
||||||
|
|
||||||
// Form handling
|
// Form handling - now shows preview instead of sending directly
|
||||||
this.form.addEventListener('submit', (e) => this.handleSubmit(e));
|
this.form.addEventListener('submit', (e) => this.handlePreview(e));
|
||||||
|
|
||||||
// Character counter
|
// Character counter
|
||||||
this.messageTextarea.addEventListener('input', () => this.updateCharCounter());
|
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) => {
|
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();
|
this.closeModal();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,7 +90,7 @@ class EmailComposer {
|
|||||||
document.getElementById('email-message').value = '';
|
document.getElementById('email-message').value = '';
|
||||||
|
|
||||||
// Set default subject
|
// 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.updateCharCounter();
|
||||||
this.modal.style.display = 'block';
|
this.modal.style.display = 'block';
|
||||||
@ -68,7 +101,24 @@ class EmailComposer {
|
|||||||
|
|
||||||
closeModal() {
|
closeModal() {
|
||||||
this.modal.style.display = 'none';
|
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.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() {
|
updateCharCounter() {
|
||||||
@ -141,9 +191,17 @@ class EmailComposer {
|
|||||||
return suspiciousPatterns.some(pattern => pattern.test(text));
|
return suspiciousPatterns.some(pattern => pattern.test(text));
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleSubmit(e) {
|
async handlePreview(e) {
|
||||||
e.preventDefault();
|
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();
|
const errors = this.validateForm();
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
window.messageDisplay.show(errors.join('<br>'), 'error');
|
window.messageDisplay.show(errors.join('<br>'), 'error');
|
||||||
@ -155,22 +213,118 @@ class EmailComposer {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
submitButton.disabled = true;
|
submitButton.disabled = true;
|
||||||
submitButton.textContent = 'Sending...';
|
submitButton.textContent = 'Loading Preview...';
|
||||||
|
|
||||||
const emailData = {
|
this.currentEmailData = {
|
||||||
recipientEmail: document.getElementById('recipient-email').value,
|
recipientEmail: document.getElementById('recipient-email').value,
|
||||||
senderName: document.getElementById('sender-name').value.trim(),
|
senderName: document.getElementById('sender-name').value.trim(),
|
||||||
senderEmail: document.getElementById('sender-email').value.trim(),
|
senderEmail: document.getElementById('sender-email').value.trim(),
|
||||||
subject: document.getElementById('email-subject').value.trim(),
|
subject: document.getElementById('email-subject').value.trim(),
|
||||||
message: document.getElementById('email-message').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) {
|
if (result.success) {
|
||||||
window.messageDisplay.show('Email sent successfully! Your representative will receive your message.', 'success');
|
window.messageDisplay.show('Email sent successfully! Your representative will receive your message.', 'success');
|
||||||
this.closeModal();
|
this.closePreviewModal();
|
||||||
|
this.currentEmailData = null;
|
||||||
} else {
|
} else {
|
||||||
throw new Error(result.message || 'Failed to send email');
|
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');
|
window.messageDisplay.show(`Failed to send email: ${error.message}`, 'error');
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
submitButton.disabled = false;
|
confirmButton.disabled = false;
|
||||||
submitButton.textContent = originalText;
|
confirmButton.textContent = originalText;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -51,7 +51,7 @@ class EmailTesting {
|
|||||||
try {
|
try {
|
||||||
const response = await this.apiClient.post('/api/emails/test', {
|
const response = await this.apiClient.post('/api/emails/test', {
|
||||||
subject: 'Quick Test Email',
|
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) {
|
if (response.success) {
|
||||||
|
|||||||
@ -13,15 +13,38 @@ class MainApp {
|
|||||||
|
|
||||||
// Add global error handling
|
// Add global error handling
|
||||||
window.addEventListener('error', (e) => {
|
window.addEventListener('error', (e) => {
|
||||||
|
// Only log and show message for actual errors, not null/undefined
|
||||||
|
if (e.error) {
|
||||||
console.error('Global error:', 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
|
// Add unhandled promise rejection handling
|
||||||
window.addEventListener('unhandledrejection', (e) => {
|
window.addEventListener('unhandledrejection', (e) => {
|
||||||
|
if (e.reason) {
|
||||||
console.error('Unhandled promise rejection:', 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();
|
e.preventDefault();
|
||||||
|
} else {
|
||||||
|
console.log('Non-critical promise rejection:', e);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -136,9 +136,8 @@ class PostalLookup {
|
|||||||
locationText += ` • ${data.city}, ${data.province}`;
|
locationText += ` • ${data.city}, ${data.province}`;
|
||||||
}
|
}
|
||||||
if (data.source || apiResponse.source) {
|
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;
|
this.locationDetails.textContent = locationText;
|
||||||
|
|
||||||
// Show representatives
|
// Show representatives
|
||||||
|
|||||||
@ -117,7 +117,7 @@ class RepresentativesDisplay {
|
|||||||
data-name="${name}"
|
data-name="${name}"
|
||||||
data-office="${office}"
|
data-office="${office}"
|
||||||
data-district="${district}">
|
data-district="${district}">
|
||||||
Send Email
|
📧 Send Email
|
||||||
</button>` :
|
</button>` :
|
||||||
'<span class="text-muted">No email available</span>';
|
'<span class="text-muted">No email available</span>';
|
||||||
|
|
||||||
@ -131,8 +131,11 @@ class RepresentativesDisplay {
|
|||||||
📞 Call
|
📞 Call
|
||||||
</button>` : '';
|
</button>` : '';
|
||||||
|
|
||||||
|
// Add visit buttons for all available office addresses
|
||||||
|
const visitButtons = this.createVisitButtons(rep.offices || [], name, office);
|
||||||
|
|
||||||
const profileUrl = rep.url ?
|
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
|
// Generate initials for fallback
|
||||||
const initials = name.split(' ')
|
const initials = name.split(' ')
|
||||||
@ -145,7 +148,7 @@ class RepresentativesDisplay {
|
|||||||
`<div class="rep-photo">
|
`<div class="rep-photo">
|
||||||
<img src="${photoUrl}"
|
<img src="${photoUrl}"
|
||||||
alt="${name}"
|
alt="${name}"
|
||||||
onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';"
|
data-fallback-initials="${initials}"
|
||||||
loading="lazy">
|
loading="lazy">
|
||||||
<div class="rep-photo-fallback" style="display: none;">
|
<div class="rep-photo-fallback" style="display: none;">
|
||||||
${initials}
|
${initials}
|
||||||
@ -176,6 +179,7 @@ class RepresentativesDisplay {
|
|||||||
${callButton}
|
${callButton}
|
||||||
${profileUrl}
|
${profileUrl}
|
||||||
</div>
|
</div>
|
||||||
|
${visitButtons ? `<div class="rep-visit-buttons">${visitButtons}</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@ -185,8 +189,63 @@ class RepresentativesDisplay {
|
|||||||
const phoneNumbers = [];
|
const phoneNumbers = [];
|
||||||
|
|
||||||
if (Array.isArray(offices)) {
|
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 => {
|
offices.forEach(office => {
|
||||||
if (office.tel) {
|
if (office.tel && !phoneNumbers.find(p => p.number === office.tel)) {
|
||||||
phoneNumbers.push({
|
phoneNumbers.push({
|
||||||
number: office.tel,
|
number: office.tel,
|
||||||
type: office.type || 'office'
|
type: office.type || 'office'
|
||||||
@ -198,6 +257,102 @@ class RepresentativesDisplay {
|
|||||||
return phoneNumbers;
|
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() {
|
attachEventListeners() {
|
||||||
// Add event listeners for compose email buttons
|
// Add event listeners for compose email buttons
|
||||||
const composeButtons = this.container.querySelectorAll('.compose-email');
|
const composeButtons = this.container.querySelectorAll('.compose-email');
|
||||||
@ -229,6 +384,33 @@ class RepresentativesDisplay {
|
|||||||
this.handleCallClick(phone, name, office, officeType);
|
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) {
|
handleCallClick(phone, name, office, officeType) {
|
||||||
@ -247,6 +429,51 @@ class RepresentativesDisplay {
|
|||||||
window.location.href = telLink;
|
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
|
// Initialize when DOM is loaded
|
||||||
|
|||||||
583
influence/app/public/js/representatives-map.js
Normal file
583
influence/app/public/js/representatives-map.js
Normal 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: '© <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
|
||||||
|
};
|
||||||
230
influence/app/public/terms.html
Normal file
230
influence/app/public/terms.html
Normal 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>© 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>
|
||||||
@ -41,6 +41,21 @@ router.post(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Email endpoints
|
// 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(
|
router.post(
|
||||||
'/emails/send',
|
'/emails/send',
|
||||||
rateLimiter.email, // General hourly rate limit
|
rateLimiter.email, // General hourly rate limit
|
||||||
@ -58,19 +73,6 @@ router.post(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Email testing endpoints
|
// 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(
|
router.post(
|
||||||
'/emails/test',
|
'/emails/test',
|
||||||
requireAdmin,
|
requireAdmin,
|
||||||
|
|||||||
@ -12,15 +12,19 @@ const { requireAdmin } = require('./middleware/auth');
|
|||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3333;
|
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
|
// Security middleware
|
||||||
app.use(helmet({
|
app.use(helmet({
|
||||||
contentSecurityPolicy: {
|
contentSecurityPolicy: {
|
||||||
directives: {
|
directives: {
|
||||||
defaultSrc: ["'self'"],
|
defaultSrc: ["'self'"],
|
||||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
styleSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com"],
|
||||||
scriptSrc: ["'self'"],
|
scriptSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com", "https://static.cloudflareinsights.com"],
|
||||||
imgSrc: ["'self'", "data:", "https:"],
|
imgSrc: ["'self'", "data:", "https:"],
|
||||||
connectSrc: ["'self'"],
|
connectSrc: ["'self'", "https://cloudflareinsights.com"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@ -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 = {
|
const templateVariables = {
|
||||||
MESSAGE: message,
|
MESSAGE: message,
|
||||||
SENDER_NAME: senderName,
|
SENDER_NAME: senderName,
|
||||||
SENDER_EMAIL: senderEmail,
|
SENDER_EMAIL: senderEmail,
|
||||||
POSTAL_CODE: postalCode
|
POSTAL_CODE: postalCode,
|
||||||
|
RECIPIENT_NAME: recipientName || 'Representative'
|
||||||
};
|
};
|
||||||
|
|
||||||
const emailOptions = {
|
const emailOptions = {
|
||||||
@ -276,7 +280,7 @@ class EmailService {
|
|||||||
name: process.env.SMTP_FROM_NAME
|
name: process.env.SMTP_FROM_NAME
|
||||||
},
|
},
|
||||||
replyTo: senderEmail,
|
replyTo: senderEmail,
|
||||||
subject: subject
|
subject: finalSubject
|
||||||
};
|
};
|
||||||
|
|
||||||
return await this.sendTemplatedEmail('representative-contact', templateVariables, emailOptions);
|
return await this.sendTemplatedEmail('representative-contact', templateVariables, emailOptions);
|
||||||
|
|||||||
@ -70,7 +70,7 @@ class EmailTemplateService {
|
|||||||
|
|
||||||
// Add default variables
|
// Add default variables
|
||||||
const defaultVariables = {
|
const defaultVariables = {
|
||||||
APP_NAME: process.env.APP_NAME || 'BNKops Influence Tool',
|
APP_NAME: process.env.APP_NAME || 'BNKops Influence Campaign',
|
||||||
TIMESTAMP: new Date().toLocaleString(),
|
TIMESTAMP: new Date().toLocaleString(),
|
||||||
...variables
|
...variables
|
||||||
};
|
};
|
||||||
|
|||||||
@ -80,19 +80,35 @@ class NocoDBService {
|
|||||||
// Create record
|
// Create record
|
||||||
async create(tableId, data) {
|
async create(tableId, data) {
|
||||||
try {
|
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) => {
|
const cleanData = Object.keys(data).reduce((clean, key) => {
|
||||||
if (data[key] !== null && data[key] !== undefined) {
|
// Skip null/undefined values
|
||||||
clean[key] = data[key];
|
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;
|
return clean;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
|
console.log(`Creating record in table ${tableId} with data:`, JSON.stringify(cleanData, null, 2));
|
||||||
|
|
||||||
const url = this.getTableUrl(tableId);
|
const url = this.getTableUrl(tableId);
|
||||||
const response = await this.client.post(url, cleanData);
|
const response = await this.client.post(url, cleanData);
|
||||||
|
console.log(`Record created successfully in table ${tableId}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error) {
|
} 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;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -122,7 +138,24 @@ class NocoDBService {
|
|||||||
async storeRepresentatives(postalCode, representatives) {
|
async storeRepresentatives(postalCode, representatives) {
|
||||||
try {
|
try {
|
||||||
const stored = [];
|
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) {
|
for (const rep of representatives) {
|
||||||
const record = {
|
const record = {
|
||||||
'Postal Code': postalCode,
|
'Postal Code': postalCode,
|
||||||
@ -134,23 +167,33 @@ class NocoDBService {
|
|||||||
'Representative Set Name': rep.representative_set_name || '',
|
'Representative Set Name': rep.representative_set_name || '',
|
||||||
'Profile URL': rep.url || '',
|
'Profile URL': rep.url || '',
|
||||||
'Photo URL': rep.photo_url || '',
|
'Photo URL': rep.photo_url || '',
|
||||||
|
'Offices': rep.offices ? JSON.stringify(rep.offices) : '[]',
|
||||||
'Cached At': new Date().toISOString()
|
'Cached At': new Date().toISOString()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
const result = await this.create(this.tableIds.representatives, record);
|
const result = await this.create(this.tableIds.representatives, record);
|
||||||
stored.push(result);
|
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 };
|
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);
|
} catch (error) {
|
||||||
return { success: false, error: error.message };
|
// 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 {
|
||||||
// Try to query with the most likely column name
|
// Try to query with the most likely column name
|
||||||
const response = await this.getAll(this.tableIds.representatives, {
|
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) {
|
} catch (error) {
|
||||||
// If we get a 502 or other server error, just return empty array
|
// If we get a 502 or other server error, just return empty array
|
||||||
if (error.response && (error.response.status === 502 || error.response.status >= 500)) {
|
if (error.response && (error.response.status === 502 || error.response.status >= 500)) {
|
||||||
@ -200,6 +258,7 @@ class NocoDBService {
|
|||||||
'Sender Name': emailData.senderName,
|
'Sender Name': emailData.senderName,
|
||||||
'Sender Email': emailData.senderEmail,
|
'Sender Email': emailData.senderEmail,
|
||||||
'Subject': emailData.subject,
|
'Subject': emailData.subject,
|
||||||
|
'Message': emailData.message || '',
|
||||||
'Postal Code': emailData.postalCode,
|
'Postal Code': emailData.postalCode,
|
||||||
'Status': emailData.status,
|
'Status': emailData.status,
|
||||||
'Sent At': emailData.timestamp,
|
'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
|
// Check if an email was recently sent to this recipient from this IP
|
||||||
async checkRecentEmailSend(senderIP, recipientEmail, windowMinutes = 5) {
|
async checkRecentEmailSend(senderIP, recipientEmail, windowMinutes = 5) {
|
||||||
try {
|
try {
|
||||||
@ -239,7 +333,7 @@ class NocoDBService {
|
|||||||
const conditions = [];
|
const conditions = [];
|
||||||
|
|
||||||
if (filters.postalCode) {
|
if (filters.postalCode) {
|
||||||
conditions.push(`(postal_code,eq,${filters.postalCode})`);
|
conditions.push(`(Postal Code,eq,${filters.postalCode})`);
|
||||||
}
|
}
|
||||||
if (filters.senderEmail) {
|
if (filters.senderEmail) {
|
||||||
conditions.push(`(sender_email,eq,${filters.senderEmail})`);
|
conditions.push(`(sender_email,eq,${filters.senderEmail})`);
|
||||||
@ -335,6 +429,7 @@ class NocoDBService {
|
|||||||
'Allow Mailto Link': campaignData.allow_mailto_link,
|
'Allow Mailto Link': campaignData.allow_mailto_link,
|
||||||
'Collect User Info': campaignData.collect_user_info,
|
'Collect User Info': campaignData.collect_user_info,
|
||||||
'Show Email Count': campaignData.show_email_count,
|
'Show Email Count': campaignData.show_email_count,
|
||||||
|
'Allow Email Editing': campaignData.allow_email_editing,
|
||||||
'Target Government Levels': campaignData.target_government_levels
|
'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.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.collect_user_info !== undefined) mappedUpdates['Collect User Info'] = updates.collect_user_info;
|
||||||
if (updates.show_email_count !== undefined) mappedUpdates['Show Email Count'] = updates.show_email_count;
|
if (updates.show_email_count !== undefined) mappedUpdates['Show Email Count'] = updates.show_email_count;
|
||||||
|
if (updates.allow_email_editing !== undefined) mappedUpdates['Allow Email Editing'] = updates.allow_email_editing;
|
||||||
if (updates.target_government_levels !== undefined) mappedUpdates['Target Government Levels'] = updates.target_government_levels;
|
if (updates.target_government_levels !== undefined) mappedUpdates['Target Government Levels'] = updates.target_government_levels;
|
||||||
if (updates.updated_at !== undefined) mappedUpdates['UpdatedAt'] = updates.updated_at;
|
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
|
// 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);
|
const response = await this.create(this.tableIds.campaignEmails, mappedData);
|
||||||
return response;
|
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) {
|
} catch (error) {
|
||||||
console.error('Log campaign email failed:', error);
|
console.error('Log campaign email failed:', error.response?.data || error.message);
|
||||||
throw error;
|
// Return a failure response but don't throw - logging should not break the main flow
|
||||||
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -78,15 +78,15 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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="content">
|
||||||
<div class="message-body">
|
<div class="message-body">
|
||||||
|
<p>Dear {{RECIPIENT_NAME}},</p>
|
||||||
|
|
||||||
{{MESSAGE}}
|
{{MESSAGE}}
|
||||||
|
|
||||||
|
<p>Sincerely,<br>
|
||||||
|
{{SENDER_NAME}}<br>
|
||||||
|
{{POSTAL_CODE}}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sender-info">
|
<div class="sender-info">
|
||||||
|
|||||||
@ -1,7 +1,11 @@
|
|||||||
Message from Constituent - {{APP_NAME}}
|
Dear {{RECIPIENT_NAME}},
|
||||||
|
|
||||||
{{MESSAGE}}
|
{{MESSAGE}}
|
||||||
|
|
||||||
|
Sincerely,
|
||||||
|
{{SENDER_NAME}}
|
||||||
|
{{POSTAL_CODE}}
|
||||||
|
|
||||||
---
|
---
|
||||||
Constituent Information:
|
Constituent Information:
|
||||||
Name: {{SENDER_NAME}}
|
Name: {{SENDER_NAME}}
|
||||||
|
|||||||
@ -26,6 +26,11 @@ const general = rateLimit({
|
|||||||
},
|
},
|
||||||
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
|
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
|
||||||
legacyHeaders: false, // Disable the `X-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)
|
// Email sending rate limiter (general - keeps existing behavior)
|
||||||
@ -39,11 +44,16 @@ const email = rateLimit({
|
|||||||
standardHeaders: true,
|
standardHeaders: true,
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
skipSuccessfulRequests: false, // Don't skip counting successful requests
|
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
|
// Custom middleware for per-recipient email rate limiting
|
||||||
const perRecipientEmailLimiter = (req, res, next) => {
|
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;
|
const recipientEmail = req.body.recipientEmail;
|
||||||
|
|
||||||
if (!recipientEmail) {
|
if (!recipientEmail) {
|
||||||
@ -80,6 +90,11 @@ const representAPI = rateLimit({
|
|||||||
},
|
},
|
||||||
standardHeaders: true,
|
standardHeaders: true,
|
||||||
legacyHeaders: false,
|
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 = {
|
module.exports = {
|
||||||
|
|||||||
@ -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
|
# Copy this file to .env and update with your actual values
|
||||||
|
|
||||||
# NocoDB Configuration
|
# NocoDB Configuration
|
||||||
@ -57,7 +57,7 @@ NOCODB_TABLE_USERS=
|
|||||||
# SMTP_USER=
|
# SMTP_USER=
|
||||||
# SMTP_PASS=
|
# SMTP_PASS=
|
||||||
# SMTP_FROM_EMAIL=dev@albertainfluence.local
|
# SMTP_FROM_EMAIL=dev@albertainfluence.local
|
||||||
# SMTP_FROM_NAME="Alberta Influence Campaign (DEV)"
|
# SMTP_FROM_NAME="BNKops Influence Campaign (DEV)"
|
||||||
|
|
||||||
# Security Notes:
|
# Security Notes:
|
||||||
# - Keep your .env file secure and never commit it to version control
|
# - Keep your .env file secure and never commit it to version control
|
||||||
|
|||||||
@ -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
|
## Authentication System
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
# Alberta Influence Campaign Tool - Complete Setup Guide
|
# BNKops Influence Campaign Tool - Complete Setup Guide
|
||||||
|
|
||||||
## Project Overview
|
## 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.
|
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_USER=apikey
|
||||||
SMTP_PASS=your_smtp_password_here
|
SMTP_PASS=your_smtp_password_here
|
||||||
SMTP_FROM_EMAIL=noreply@yourcampaign.ca
|
SMTP_FROM_EMAIL=noreply@yourcampaign.ca
|
||||||
SMTP_FROM_NAME=Alberta Influence Campaign
|
SMTP_FROM_NAME=BNKops Influence Campaign
|
||||||
|
|
||||||
# Admin Configuration
|
# Admin Configuration
|
||||||
ADMIN_PASSWORD=secure_admin_password_here
|
ADMIN_PASSWORD=secure_admin_password_here
|
||||||
@ -308,7 +308,7 @@ class RepresentAPIService {
|
|||||||
const response = await axios.get(`${this.baseURL}/postcodes/${formattedPostal}/`, {
|
const response = await axios.get(`${this.baseURL}/postcodes/${formattedPostal}/`, {
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
headers: {
|
headers: {
|
||||||
'User-Agent': 'Alberta Influence Campaign Tool'
|
'User-Agent': 'BNKops Influence Campaign Tool'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -582,7 +582,7 @@ class EmailService {
|
|||||||
replyTo: from_email,
|
replyTo: from_email,
|
||||||
headers: {
|
headers: {
|
||||||
'X-Sender-Email': from_email,
|
'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('')}
|
${message.split('\n').map(para => `<p>${para}</p>`).join('')}
|
||||||
</div>
|
</div>
|
||||||
<div class="footer">
|
<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}
|
The constituent's email address is: ${from_email}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
# Instructions
|
# 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
|
## Environment Setup
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +1,22 @@
|
|||||||
#!/bin/bash
|
#!/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
|
# Based on the successful map setup script
|
||||||
# This script creates tables in your existing NocoDB project
|
# 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
|
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)
|
# Change to the influence root directory (parent of scripts directory)
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
INFLUENCE_ROOT="$(dirname "$SCRIPT_DIR")"
|
INFLUENCE_ROOT="$(dirname "$SCRIPT_DIR")"
|
||||||
@ -37,6 +48,89 @@ print_error() {
|
|||||||
echo -e "${RED}[ERROR]${NC} $1" >&2
|
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
|
# Load environment variables
|
||||||
if [ -f ".env" ]; then
|
if [ -f ".env" ]; then
|
||||||
set -a
|
set -a
|
||||||
@ -158,6 +252,440 @@ test_api_connectivity() {
|
|||||||
fi
|
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
|
# Function to create a table
|
||||||
create_table() {
|
create_table() {
|
||||||
local base_id=$1
|
local base_id=$1
|
||||||
@ -270,6 +798,12 @@ create_representatives_table() {
|
|||||||
"uidt": "URL",
|
"uidt": "URL",
|
||||||
"rqd": false
|
"rqd": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"column_name": "offices",
|
||||||
|
"title": "Offices",
|
||||||
|
"uidt": "LongText",
|
||||||
|
"rqd": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"column_name": "cached_at",
|
"column_name": "cached_at",
|
||||||
"title": "Cached At",
|
"title": "Cached At",
|
||||||
@ -293,10 +827,7 @@ create_email_logs_table() {
|
|||||||
{
|
{
|
||||||
"column_name": "id",
|
"column_name": "id",
|
||||||
"title": "ID",
|
"title": "ID",
|
||||||
"uidt": "ID",
|
"uidt": "ID"
|
||||||
"pk": true,
|
|
||||||
"ai": true,
|
|
||||||
"rqd": true
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"column_name": "recipient_email",
|
"column_name": "recipient_email",
|
||||||
@ -322,6 +853,12 @@ create_email_logs_table() {
|
|||||||
"uidt": "SingleLineText",
|
"uidt": "SingleLineText",
|
||||||
"rqd": false
|
"rqd": false
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"column_name": "message",
|
||||||
|
"title": "Message",
|
||||||
|
"uidt": "LongText",
|
||||||
|
"rqd": false
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"column_name": "postal_code",
|
"column_name": "postal_code",
|
||||||
"title": "Postal Code",
|
"title": "Postal Code",
|
||||||
@ -336,10 +873,17 @@ create_email_logs_table() {
|
|||||||
"colOptions": {
|
"colOptions": {
|
||||||
"options": [
|
"options": [
|
||||||
{"title": "sent", "color": "#00ff00"},
|
{"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",
|
"column_name": "sent_at",
|
||||||
"title": "Sent At",
|
"title": "Sent At",
|
||||||
@ -504,6 +1048,12 @@ create_campaigns_table() {
|
|||||||
"uidt": "Checkbox",
|
"uidt": "Checkbox",
|
||||||
"cdf": "true"
|
"cdf": "true"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"column_name": "allow_email_editing",
|
||||||
|
"title": "Allow Email Editing",
|
||||||
|
"uidt": "Checkbox",
|
||||||
|
"cdf": "false"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"column_name": "target_government_levels",
|
"column_name": "target_government_levels",
|
||||||
"title": "Target Government Levels",
|
"title": "Target Government Levels",
|
||||||
@ -700,19 +1250,19 @@ create_users_table() {
|
|||||||
# Function to create a new base
|
# Function to create a new base
|
||||||
create_base() {
|
create_base() {
|
||||||
local base_data='{
|
local base_data='{
|
||||||
"title": "Alberta Influence Campaign Tool",
|
"title": "BNKops Influence Campaign Tool",
|
||||||
"type": "database"
|
"type": "database"
|
||||||
}'
|
}'
|
||||||
|
|
||||||
local response
|
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
|
if [[ $? -eq 0 && -n "$response" ]]; then
|
||||||
local base_id
|
local base_id
|
||||||
base_id=$(echo "$response" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
base_id=$(echo "$response" | grep -o '"id":"[^"]*"' | head -1 | cut -d'"' -f4)
|
||||||
|
|
||||||
if [[ -n "$base_id" ]]; then
|
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"
|
echo "$base_id"
|
||||||
else
|
else
|
||||||
print_error "Failed to extract base ID from response"
|
print_error "Failed to extract base ID from response"
|
||||||
@ -786,7 +1336,10 @@ update_env_with_table_ids() {
|
|||||||
|
|
||||||
# Main execution
|
# Main execution
|
||||||
main() {
|
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 "============================================================"
|
print_status "============================================================"
|
||||||
|
|
||||||
# First test API connectivity
|
# First test API connectivity
|
||||||
@ -795,6 +1348,29 @@ main() {
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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 ""
|
||||||
print_status "Creating new base for Influence Campaign Tool..."
|
print_status "Creating new base for Influence Campaign Tool..."
|
||||||
|
|
||||||
@ -863,13 +1439,63 @@ main() {
|
|||||||
# Wait a moment for tables to be fully created
|
# Wait a moment for tables to be fully created
|
||||||
sleep 3
|
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_status "============================================================"
|
print_status "============================================================"
|
||||||
print_success "NocoDB Setup completed successfully!"
|
print_success "NocoDB Setup completed successfully!"
|
||||||
print_status "============================================================"
|
print_status "============================================================"
|
||||||
|
|
||||||
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 "Created tables:"
|
||||||
print_status " - influence_representatives (ID: $REPRESENTATIVES_TABLE_ID)"
|
print_status " - influence_representatives (ID: $REPRESENTATIVES_TABLE_ID)"
|
||||||
print_status " - influence_email_logs (ID: $EMAIL_LOGS_TABLE_ID)"
|
print_status " - influence_email_logs (ID: $EMAIL_LOGS_TABLE_ID)"
|
||||||
@ -907,7 +1533,7 @@ main() {
|
|||||||
print_status "============================================================"
|
print_status "============================================================"
|
||||||
|
|
||||||
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 "Updated .env file with project ID and all table IDs"
|
||||||
print_status ""
|
print_status ""
|
||||||
print_status "Next steps:"
|
print_status "Next steps:"
|
||||||
@ -918,21 +1544,20 @@ main() {
|
|||||||
print_status "5. Access the admin panel at: http://localhost:3333/admin.html"
|
print_status "5. Access the admin panel at: http://localhost:3333/admin.html"
|
||||||
|
|
||||||
print_status ""
|
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
|
# Check if script is being run directly
|
||||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
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 "$@"
|
main "$@"
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
Loading…
x
Reference in New Issue
Block a user