Verfied response system for electeds
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
const nocodbService = require('../services/nocodb');
|
||||
const emailService = require('../services/email');
|
||||
const crypto = require('crypto');
|
||||
const { validateResponse } = require('../utils/validators');
|
||||
|
||||
/**
|
||||
@@ -118,6 +120,48 @@ async function submitResponse(req, res) {
|
||||
screenshotUrl = `/uploads/responses/${req.file.filename}`;
|
||||
}
|
||||
|
||||
// DEBUG: Log verification-related fields
|
||||
console.log('=== VERIFICATION DEBUG ===');
|
||||
console.log('send_verification from form:', responseData.send_verification);
|
||||
console.log('representative_email from form:', responseData.representative_email);
|
||||
console.log('representative_name from form:', responseData.representative_name);
|
||||
|
||||
// Generate verification token if verification is requested and email is provided
|
||||
let verificationToken = null;
|
||||
let verificationSentAt = null;
|
||||
|
||||
// Handle send_verification - could be string, boolean, or array from form
|
||||
let sendVerificationValue = responseData.send_verification;
|
||||
if (Array.isArray(sendVerificationValue)) {
|
||||
// If it's an array, check if any value indicates true
|
||||
sendVerificationValue = sendVerificationValue.some(val => val === 'true' || val === true || val === 'on');
|
||||
}
|
||||
const sendVerification = sendVerificationValue === 'true' || sendVerificationValue === true || sendVerificationValue === 'on';
|
||||
console.log('sendVerification evaluated to:', sendVerification);
|
||||
|
||||
// Handle representative_email - could be string or array from form
|
||||
let representativeEmail = responseData.representative_email;
|
||||
if (Array.isArray(representativeEmail)) {
|
||||
representativeEmail = representativeEmail[0]; // Take first email if array
|
||||
}
|
||||
representativeEmail = representativeEmail || null;
|
||||
console.log('representativeEmail after processing:', representativeEmail);
|
||||
|
||||
if (sendVerification && representativeEmail) {
|
||||
// Generate a secure random token
|
||||
verificationToken = crypto.randomBytes(32).toString('hex');
|
||||
verificationSentAt = new Date().toISOString();
|
||||
console.log('Generated verification token:', verificationToken.substring(0, 16) + '...');
|
||||
console.log('Verification sent at:', verificationSentAt);
|
||||
} else {
|
||||
console.log('Skipping verification token generation. sendVerification:', sendVerification, 'representativeEmail:', representativeEmail);
|
||||
}
|
||||
|
||||
// Normalize is_anonymous checkbox value
|
||||
const isAnonymous = responseData.is_anonymous === true ||
|
||||
responseData.is_anonymous === 'true' ||
|
||||
responseData.is_anonymous === 'on';
|
||||
|
||||
// Prepare response data for NocoDB
|
||||
const newResponse = {
|
||||
campaign_id: campaign.ID || campaign.Id || campaign.id || campaign['Campaign ID'],
|
||||
@@ -132,9 +176,14 @@ async function submitResponse(req, res) {
|
||||
submitted_by_name: responseData.submitted_by_name || null,
|
||||
submitted_by_email: responseData.submitted_by_email || null,
|
||||
submitted_by_user_id: req.user?.id || null,
|
||||
is_anonymous: responseData.is_anonymous || false,
|
||||
is_anonymous: isAnonymous,
|
||||
status: 'pending', // All submissions start as pending
|
||||
is_verified: false,
|
||||
representative_email: representativeEmail,
|
||||
verification_token: verificationToken,
|
||||
verification_sent_at: verificationSentAt,
|
||||
verified_at: null,
|
||||
verified_by: null,
|
||||
upvote_count: 0,
|
||||
submitted_ip: req.ip || req.connection.remoteAddress
|
||||
};
|
||||
@@ -144,10 +193,50 @@ async function submitResponse(req, res) {
|
||||
// Create response in database
|
||||
const createdResponse = await nocodbService.createRepresentativeResponse(newResponse);
|
||||
|
||||
// Send verification email if requested
|
||||
let verificationEmailSent = false;
|
||||
if (sendVerification && representativeEmail && verificationToken) {
|
||||
try {
|
||||
const baseUrl = process.env.BASE_URL || `${req.protocol}://${req.get('host')}`;
|
||||
const verificationUrl = `${baseUrl}/api/responses/${createdResponse.id}/verify/${verificationToken}`;
|
||||
const reportUrl = `${baseUrl}/api/responses/${createdResponse.id}/report/${verificationToken}`;
|
||||
|
||||
const campaignTitle = campaign.Title || campaign.title || 'Unknown Campaign';
|
||||
const submittedDate = new Date().toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
|
||||
await emailService.sendResponseVerification({
|
||||
representativeEmail,
|
||||
representativeName: responseData.representative_name,
|
||||
campaignTitle,
|
||||
responseType: responseData.response_type,
|
||||
responseText: responseData.response_text,
|
||||
submittedDate,
|
||||
submitterName: responseData.is_anonymous ? 'Anonymous' : (responseData.submitted_by_name || 'A constituent'),
|
||||
verificationUrl,
|
||||
reportUrl
|
||||
});
|
||||
|
||||
verificationEmailSent = true;
|
||||
console.log('Verification email sent successfully to:', representativeEmail);
|
||||
} catch (emailError) {
|
||||
console.error('Failed to send verification email:', emailError);
|
||||
// Don't fail the whole request if email fails
|
||||
}
|
||||
}
|
||||
|
||||
const responseMessage = verificationEmailSent
|
||||
? 'Response submitted successfully. A verification email has been sent to the representative. Your response will be visible after moderation.'
|
||||
: 'Response submitted successfully. It will be visible after moderation.';
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Response submitted successfully. It will be visible after moderation.',
|
||||
response: createdResponse
|
||||
message: responseMessage,
|
||||
response: createdResponse,
|
||||
verificationEmailSent
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
@@ -534,6 +623,290 @@ async function deleteResponse(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a response using verification token
|
||||
* Public endpoint - no authentication required
|
||||
* @param {Object} req - Express request object
|
||||
* @param {Object} res - Express response object
|
||||
*/
|
||||
async function verifyResponse(req, res) {
|
||||
try {
|
||||
const { id, token } = req.params;
|
||||
|
||||
console.log('=== VERIFICATION ATTEMPT ===');
|
||||
console.log('Response ID:', id);
|
||||
console.log('Token from URL:', token);
|
||||
|
||||
// Get the response
|
||||
const response = await nocodbService.getRepresentativeResponseById(id);
|
||||
if (!response) {
|
||||
console.log('Response not found for ID:', id);
|
||||
return res.status(404).send(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Response Not Found</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
|
||||
h1 { color: #e74c3c; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>❌ Response Not Found</h1>
|
||||
<p>The response you're trying to verify could not be found.</p>
|
||||
<p>It may have been deleted or the link may be incorrect.</p>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
}
|
||||
|
||||
console.log('Response found:', {
|
||||
id: response.id,
|
||||
verification_token: response.verification_token,
|
||||
verification_token_type: typeof response.verification_token,
|
||||
token_from_url: token,
|
||||
token_from_url_type: typeof token,
|
||||
tokens_match: response.verification_token === token
|
||||
});
|
||||
|
||||
// Check if token matches
|
||||
if (response.verification_token !== token) {
|
||||
console.log('Token mismatch! Expected:', response.verification_token, 'Got:', token);
|
||||
return res.status(403).send(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Invalid Verification Token</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
|
||||
h1 { color: #e74c3c; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>❌ Invalid Verification Token</h1>
|
||||
<p>The verification link is invalid or has expired.</p>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
}
|
||||
|
||||
// Check if already verified
|
||||
if (response.verified_at) {
|
||||
return res.send(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Already Verified</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
|
||||
h1 { color: #3498db; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>ℹ️ Already Verified</h1>
|
||||
<p>This response has already been verified on ${new Date(response.verified_at).toLocaleDateString()}.</p>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
}
|
||||
|
||||
// Update response to verified
|
||||
const updatedData = {
|
||||
is_verified: true,
|
||||
verified_at: new Date().toISOString(),
|
||||
verified_by: response.representative_email || 'Representative',
|
||||
status: 'approved' // Auto-approve when verified by representative
|
||||
};
|
||||
|
||||
await nocodbService.updateRepresentativeResponse(id, updatedData);
|
||||
|
||||
res.send(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Response Verified</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
text-align: center;
|
||||
padding: 50px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
color: #333;
|
||||
padding: 40px;
|
||||
border-radius: 10px;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
}
|
||||
h1 { color: #27ae60; margin-top: 0; }
|
||||
.checkmark { font-size: 60px; }
|
||||
a { color: #3498db; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="checkmark">✅</div>
|
||||
<h1>Response Verified!</h1>
|
||||
<p>Thank you for verifying this response.</p>
|
||||
<p>The response has been marked as verified and will now appear with a verification badge on the Response Wall.</p>
|
||||
<p style="margin-top: 30px; font-size: 14px; color: #7f8c8d;">
|
||||
You can close this window now.
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error verifying response:', error);
|
||||
res.status(500).send(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Verification Error</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
|
||||
h1 { color: #e74c3c; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>❌ Verification Error</h1>
|
||||
<p>An error occurred while verifying the response.</p>
|
||||
<p>Please try again later or contact support.</p>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Report a response as invalid using verification token
|
||||
* Public endpoint - no authentication required
|
||||
* @param {Object} req - Express request object
|
||||
* @param {Object} res - Express response object
|
||||
*/
|
||||
async function reportResponse(req, res) {
|
||||
try {
|
||||
const { id, token } = req.params;
|
||||
|
||||
// Get the response
|
||||
const response = await nocodbService.getRepresentativeResponseById(id);
|
||||
if (!response) {
|
||||
return res.status(404).send(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Response Not Found</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
|
||||
h1 { color: #e74c3c; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>❌ Response Not Found</h1>
|
||||
<p>The response you're trying to report could not be found.</p>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
}
|
||||
|
||||
// Check if token matches
|
||||
if (response.verification_token !== token) {
|
||||
return res.status(403).send(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Invalid Token</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
|
||||
h1 { color: #e74c3c; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>❌ Invalid Token</h1>
|
||||
<p>The report link is invalid or has expired.</p>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
}
|
||||
|
||||
// Update response status to rejected (disputed by representative)
|
||||
const updatedData = {
|
||||
status: 'rejected',
|
||||
is_verified: false,
|
||||
verified_at: null,
|
||||
verified_by: `Disputed by ${response.representative_email || 'Representative'}`
|
||||
};
|
||||
|
||||
await nocodbService.updateRepresentativeResponse(id, updatedData);
|
||||
|
||||
res.send(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Response Reported</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
text-align: center;
|
||||
padding: 50px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
}
|
||||
.container {
|
||||
background: white;
|
||||
color: #333;
|
||||
padding: 40px;
|
||||
border-radius: 10px;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||
}
|
||||
h1 { color: #e74c3c; margin-top: 0; }
|
||||
.icon { font-size: 60px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="icon">⚠️</div>
|
||||
<h1>Response Reported</h1>
|
||||
<p>Thank you for reporting this response.</p>
|
||||
<p>The response has been marked as disputed and will be hidden from public view while we investigate.</p>
|
||||
<p style="margin-top: 30px; font-size: 14px; color: #7f8c8d;">
|
||||
You can close this window now.
|
||||
</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error reporting response:', error);
|
||||
res.status(500).send(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Report Error</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
|
||||
h1 { color: #e74c3c; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>❌ Report Error</h1>
|
||||
<p>An error occurred while reporting the response.</p>
|
||||
<p>Please try again later or contact support.</p>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getCampaignResponses,
|
||||
submitResponse,
|
||||
@@ -543,5 +916,7 @@ module.exports = {
|
||||
getAdminResponses,
|
||||
updateResponseStatus,
|
||||
updateResponse,
|
||||
deleteResponse
|
||||
deleteResponse,
|
||||
verifyResponse,
|
||||
reportResponse
|
||||
};
|
||||
|
||||
@@ -303,6 +303,47 @@
|
||||
color: #7f8c8d;
|
||||
}
|
||||
|
||||
/* Postal Lookup Styles */
|
||||
.postal-lookup-container {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.postal-lookup-container input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.postal-lookup-container .btn {
|
||||
white-space: nowrap;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
#rep-select {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 2px solid #3498db;
|
||||
border-radius: 4px;
|
||||
font-size: 0.95rem;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#rep-select option {
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#rep-select option:hover {
|
||||
background: #f0f8ff;
|
||||
}
|
||||
|
||||
#rep-select-group {
|
||||
background: #f8f9fa;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e1e8ed;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
@@ -313,6 +354,25 @@
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Checkbox styling */
|
||||
.form-group input[type="checkbox"] {
|
||||
width: auto;
|
||||
margin-right: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-group label:has(input[type="checkbox"]) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: normal;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-group input[type="checkbox"]:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
|
||||
@@ -5,6 +5,7 @@ let currentOffset = 0;
|
||||
let currentSort = 'recent';
|
||||
let currentLevel = '';
|
||||
const LIMIT = 20;
|
||||
let loadedRepresentatives = [];
|
||||
|
||||
// Initialize
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
@@ -73,9 +74,185 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
form.addEventListener('submit', handleSubmitResponse);
|
||||
}
|
||||
|
||||
// Postal code lookup button
|
||||
const lookupBtn = document.getElementById('lookup-rep-btn');
|
||||
if (lookupBtn) {
|
||||
lookupBtn.addEventListener('click', handlePostalLookup);
|
||||
}
|
||||
|
||||
// Postal code input formatting
|
||||
const postalInput = document.getElementById('modal-postal-code');
|
||||
if (postalInput) {
|
||||
postalInput.addEventListener('input', formatPostalCodeInput);
|
||||
postalInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handlePostalLookup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Representative selection
|
||||
const repSelect = document.getElementById('rep-select');
|
||||
if (repSelect) {
|
||||
repSelect.addEventListener('change', handleRepresentativeSelect);
|
||||
}
|
||||
|
||||
console.log('Response Wall: Initialization complete');
|
||||
});
|
||||
|
||||
// Postal Code Lookup Functions
|
||||
function formatPostalCodeInput(e) {
|
||||
let value = e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '');
|
||||
|
||||
// Format as A1A 1A1
|
||||
if (value.length > 3) {
|
||||
value = value.slice(0, 3) + ' ' + value.slice(3, 6);
|
||||
}
|
||||
|
||||
e.target.value = value;
|
||||
}
|
||||
|
||||
function validatePostalCode(postalCode) {
|
||||
const cleaned = postalCode.replace(/\s/g, '');
|
||||
|
||||
// Check format: Letter-Number-Letter Number-Letter-Number
|
||||
const regex = /^[A-Z]\d[A-Z]\d[A-Z]\d$/;
|
||||
if (!regex.test(cleaned)) {
|
||||
return { valid: false, message: 'Please enter a valid postal code format (A1A 1A1)' };
|
||||
}
|
||||
|
||||
// Check if it's an Alberta postal code (starts with T)
|
||||
if (!cleaned.startsWith('T')) {
|
||||
return { valid: false, message: 'This tool is designed for Alberta postal codes only (starting with T)' };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
async function handlePostalLookup() {
|
||||
const postalInput = document.getElementById('modal-postal-code');
|
||||
const postalCode = postalInput.value.trim();
|
||||
|
||||
if (!postalCode) {
|
||||
showError('Please enter a postal code');
|
||||
return;
|
||||
}
|
||||
|
||||
const validation = validatePostalCode(postalCode);
|
||||
if (!validation.valid) {
|
||||
showError(validation.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const lookupBtn = document.getElementById('lookup-rep-btn');
|
||||
lookupBtn.disabled = true;
|
||||
lookupBtn.textContent = '🔄 Searching...';
|
||||
|
||||
try {
|
||||
const response = await window.apiClient.getRepresentativesByPostalCode(postalCode);
|
||||
const data = response.data || response;
|
||||
|
||||
loadedRepresentatives = data.representatives || [];
|
||||
|
||||
if (loadedRepresentatives.length === 0) {
|
||||
showError('No representatives found for this postal code');
|
||||
document.getElementById('rep-select-group').style.display = 'none';
|
||||
} else {
|
||||
displayRepresentativeOptions(loadedRepresentatives);
|
||||
showSuccess(`Found ${loadedRepresentatives.length} representatives`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Postal lookup failed:', error);
|
||||
showError('Failed to lookup representatives: ' + error.message);
|
||||
} finally {
|
||||
lookupBtn.disabled = false;
|
||||
lookupBtn.textContent = '🔍 Search';
|
||||
}
|
||||
}
|
||||
|
||||
function displayRepresentativeOptions(representatives) {
|
||||
const repSelect = document.getElementById('rep-select');
|
||||
const repSelectGroup = document.getElementById('rep-select-group');
|
||||
|
||||
// Clear existing options
|
||||
repSelect.innerHTML = '';
|
||||
|
||||
// Add representatives as options
|
||||
representatives.forEach((rep, index) => {
|
||||
const option = document.createElement('option');
|
||||
option.value = index;
|
||||
|
||||
// Format display text
|
||||
let displayText = rep.name;
|
||||
if (rep.district_name) {
|
||||
displayText += ` - ${rep.district_name}`;
|
||||
}
|
||||
if (rep.party_name) {
|
||||
displayText += ` (${rep.party_name})`;
|
||||
}
|
||||
displayText += ` [${rep.elected_office || 'Representative'}]`;
|
||||
|
||||
option.textContent = displayText;
|
||||
repSelect.appendChild(option);
|
||||
});
|
||||
|
||||
// Show the select group
|
||||
repSelectGroup.style.display = 'block';
|
||||
}
|
||||
|
||||
function handleRepresentativeSelect(e) {
|
||||
const selectedIndex = e.target.value;
|
||||
if (selectedIndex === '') return;
|
||||
|
||||
const rep = loadedRepresentatives[selectedIndex];
|
||||
if (!rep) return;
|
||||
|
||||
// Auto-fill form fields
|
||||
document.getElementById('representative-name').value = rep.name || '';
|
||||
document.getElementById('representative-title').value = rep.elected_office || '';
|
||||
|
||||
// Set government level based on elected office
|
||||
const level = determineGovernmentLevel(rep.elected_office);
|
||||
document.getElementById('representative-level').value = level;
|
||||
|
||||
// Store email for verification option
|
||||
if (rep.email) {
|
||||
// Handle email being either string or array
|
||||
const emailValue = Array.isArray(rep.email) ? rep.email[0] : rep.email;
|
||||
document.getElementById('representative-email').value = emailValue;
|
||||
// Enable verification checkbox if we have an email
|
||||
const verificationCheckbox = document.getElementById('send-verification');
|
||||
verificationCheckbox.disabled = false;
|
||||
} else {
|
||||
document.getElementById('representative-email').value = '';
|
||||
// Disable verification checkbox if no email
|
||||
const verificationCheckbox = document.getElementById('send-verification');
|
||||
verificationCheckbox.disabled = true;
|
||||
verificationCheckbox.checked = false;
|
||||
}
|
||||
|
||||
showSuccess('Representative details filled. Please complete the rest of the form.');
|
||||
}
|
||||
|
||||
function determineGovernmentLevel(electedOffice) {
|
||||
if (!electedOffice) return '';
|
||||
|
||||
const office = electedOffice.toLowerCase();
|
||||
|
||||
if (office.includes('mp') || office.includes('member of parliament')) {
|
||||
return 'Federal';
|
||||
} else if (office.includes('mla') || office.includes('member of the legislative assembly')) {
|
||||
return 'Provincial';
|
||||
} else if (office.includes('councillor') || office.includes('councilor') || office.includes('mayor')) {
|
||||
return 'Municipal';
|
||||
} else if (office.includes('trustee') || office.includes('school board')) {
|
||||
return 'School Board';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
// Load response statistics
|
||||
async function loadResponseStats() {
|
||||
try {
|
||||
@@ -294,6 +471,19 @@ function openSubmitModal() {
|
||||
function closeSubmitModal() {
|
||||
document.getElementById('submit-modal').style.display = 'none';
|
||||
document.getElementById('submit-response-form').reset();
|
||||
|
||||
// Reset postal code lookup
|
||||
document.getElementById('rep-select-group').style.display = 'none';
|
||||
document.getElementById('rep-select').innerHTML = '';
|
||||
loadedRepresentatives = [];
|
||||
|
||||
// Reset hidden fields
|
||||
document.getElementById('representative-email').value = '';
|
||||
|
||||
// Reset verification checkbox
|
||||
const verificationCheckbox = document.getElementById('send-verification');
|
||||
verificationCheckbox.disabled = false;
|
||||
verificationCheckbox.checked = false;
|
||||
}
|
||||
|
||||
// Handle response submission
|
||||
@@ -301,6 +491,15 @@ async function handleSubmitResponse(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(e.target);
|
||||
|
||||
// Note: Both send_verification checkbox and representative_email hidden field
|
||||
// are already included in FormData from the form
|
||||
// send_verification will be 'on' if checked, undefined if not checked
|
||||
// representative_email will be populated by handleRepresentativeSelect()
|
||||
|
||||
// Get verification status for UI feedback
|
||||
const sendVerification = document.getElementById('send-verification').checked;
|
||||
const repEmail = document.getElementById('representative-email').value;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/campaigns/${currentCampaignSlug}/responses`, {
|
||||
@@ -311,7 +510,11 @@ async function handleSubmitResponse(e) {
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
showSuccess(data.message || 'Response submitted successfully! It will appear after moderation.');
|
||||
let message = data.message || 'Response submitted successfully! It will appear after moderation.';
|
||||
if (sendVerification && repEmail) {
|
||||
message += ' A verification email has been sent to the representative.';
|
||||
}
|
||||
showSuccess(message);
|
||||
closeSubmitModal();
|
||||
// Don't reload responses since submission is pending approval
|
||||
} else {
|
||||
|
||||
@@ -83,9 +83,30 @@
|
||||
<span class="close" id="modal-close-btn">×</span>
|
||||
<h2>Share a Representative Response</h2>
|
||||
<form id="submit-response-form" enctype="multipart/form-data">
|
||||
<!-- Postal Code Lookup -->
|
||||
<div class="form-group">
|
||||
<label for="modal-postal-code">Find Your Representative by Postal Code</label>
|
||||
<div class="postal-lookup-container">
|
||||
<input type="text" id="modal-postal-code" placeholder="Enter postal code (e.g., T5K 2J1)" maxlength="7">
|
||||
<button type="button" class="btn btn-secondary" id="lookup-rep-btn">🔍 Search</button>
|
||||
</div>
|
||||
<small>Search for representatives by postal code to auto-fill details</small>
|
||||
</div>
|
||||
|
||||
<!-- Representatives Selection (Hidden by default) -->
|
||||
<div class="form-group" id="rep-select-group" style="display: none;">
|
||||
<label for="rep-select">Select Representative *</label>
|
||||
<select id="rep-select" size="5">
|
||||
<!-- Options will be populated by JavaScript -->
|
||||
</select>
|
||||
<small>Click on a representative to auto-fill the form</small>
|
||||
</div>
|
||||
|
||||
<!-- Manual Entry Fields -->
|
||||
<div class="form-group">
|
||||
<label for="representative-name">Representative Name *</label>
|
||||
<input type="text" id="representative-name" name="representative_name" required>
|
||||
<small>Or enter manually if not found above</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
@@ -104,6 +125,9 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Hidden field to store representative email for verification -->
|
||||
<input type="hidden" id="representative-email" name="representative_email">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="response-type">Response Type *</label>
|
||||
<select id="response-type" name="response_type" required>
|
||||
@@ -150,6 +174,14 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" id="send-verification" name="send_verification">
|
||||
Send verification request to representative
|
||||
</label>
|
||||
<small>This will email the representative to verify this response is authentic</small>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn btn-secondary" id="cancel-submit-btn">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Submit Response</button>
|
||||
@@ -158,6 +190,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/js/api-client.js"></script>
|
||||
<script src="/js/response-wall.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -245,6 +245,10 @@ router.post(
|
||||
router.post('/responses/:id/upvote', optionalAuth, rateLimiter.general, responsesController.upvoteResponse);
|
||||
router.delete('/responses/:id/upvote', optionalAuth, rateLimiter.general, responsesController.removeUpvote);
|
||||
|
||||
// Response Verification Routes (public - no auth required)
|
||||
router.get('/responses/:id/verify/:token', responsesController.verifyResponse);
|
||||
router.get('/responses/:id/report/:token', responsesController.reportResponse);
|
||||
|
||||
// Admin and Campaign Owner Response Management Routes
|
||||
router.get('/admin/responses', requireNonTemp, rateLimiter.general, responsesController.getAdminResponses);
|
||||
router.patch('/admin/responses/:id/status', requireNonTemp, rateLimiter.general,
|
||||
|
||||
@@ -409,6 +409,62 @@ class EmailService {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send response verification email to representative
|
||||
* @param {Object} options - Email options
|
||||
* @param {string} options.representativeEmail - Representative's email address
|
||||
* @param {string} options.representativeName - Representative's name
|
||||
* @param {string} options.campaignTitle - Campaign title
|
||||
* @param {string} options.responseType - Type of response (Email, Letter, etc.)
|
||||
* @param {string} options.responseText - The actual response text
|
||||
* @param {string} options.submittedDate - Date the response was submitted
|
||||
* @param {string} options.submitterName - Name of person who submitted
|
||||
* @param {string} options.verificationUrl - URL to verify the response
|
||||
* @param {string} options.reportUrl - URL to report as invalid
|
||||
*/
|
||||
async sendResponseVerification(options) {
|
||||
try {
|
||||
const {
|
||||
representativeEmail,
|
||||
representativeName,
|
||||
campaignTitle,
|
||||
responseType,
|
||||
responseText,
|
||||
submittedDate,
|
||||
submitterName,
|
||||
verificationUrl,
|
||||
reportUrl
|
||||
} = options;
|
||||
|
||||
const templateVariables = {
|
||||
REPRESENTATIVE_NAME: representativeName,
|
||||
CAMPAIGN_TITLE: campaignTitle,
|
||||
RESPONSE_TYPE: responseType,
|
||||
RESPONSE_TEXT: responseText,
|
||||
SUBMITTED_DATE: submittedDate,
|
||||
SUBMITTER_NAME: submitterName || 'Anonymous',
|
||||
VERIFICATION_URL: verificationUrl,
|
||||
REPORT_URL: reportUrl,
|
||||
APP_NAME: process.env.APP_NAME || 'BNKops Influence',
|
||||
TIMESTAMP: new Date().toLocaleString()
|
||||
};
|
||||
|
||||
const emailOptions = {
|
||||
to: representativeEmail,
|
||||
from: {
|
||||
email: process.env.SMTP_FROM_EMAIL,
|
||||
name: process.env.SMTP_FROM_NAME
|
||||
},
|
||||
subject: `Response Verification Request - ${campaignTitle}`
|
||||
};
|
||||
|
||||
return await this.sendTemplatedEmail('response-verification', templateVariables, emailOptions);
|
||||
} catch (error) {
|
||||
console.error('Failed to send response verification email:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new EmailService();
|
||||
@@ -758,6 +758,11 @@ class NocoDBService {
|
||||
'Is Anonymous': responseData.is_anonymous,
|
||||
'Status': responseData.status,
|
||||
'Is Verified': responseData.is_verified,
|
||||
'Representative Email': responseData.representative_email,
|
||||
'Verification Token': responseData.verification_token,
|
||||
'Verification Sent At': responseData.verification_sent_at,
|
||||
'Verified At': responseData.verified_at,
|
||||
'Verified By': responseData.verified_by,
|
||||
'Upvote Count': responseData.upvote_count,
|
||||
'Submitted IP': responseData.submitted_ip
|
||||
};
|
||||
@@ -780,6 +785,11 @@ class NocoDBService {
|
||||
if (updates.upvote_count !== undefined) data['Upvote Count'] = updates.upvote_count;
|
||||
if (updates.response_text !== undefined) data['Response Text'] = updates.response_text;
|
||||
if (updates.user_comment !== undefined) data['User Comment'] = updates.user_comment;
|
||||
if (updates.representative_email !== undefined) data['Representative Email'] = updates.representative_email;
|
||||
if (updates.verification_token !== undefined) data['Verification Token'] = updates.verification_token;
|
||||
if (updates.verification_sent_at !== undefined) data['Verification Sent At'] = updates.verification_sent_at;
|
||||
if (updates.verified_at !== undefined) data['Verified At'] = updates.verified_at;
|
||||
if (updates.verified_by !== undefined) data['Verified By'] = updates.verified_by;
|
||||
|
||||
console.log(`Updating response ${responseId} with data:`, JSON.stringify(data, null, 2));
|
||||
|
||||
@@ -858,6 +868,11 @@ class NocoDBService {
|
||||
is_anonymous: data['Is Anonymous'] || data.is_anonymous || false,
|
||||
status: data['Status'] || data.status,
|
||||
is_verified: data['Is Verified'] || data.is_verified || false,
|
||||
representative_email: data['Representative Email'] || data.representative_email,
|
||||
verification_token: data['Verification Token'] || data.verification_token,
|
||||
verification_sent_at: data['Verification Sent At'] || data.verification_sent_at,
|
||||
verified_at: data['Verified At'] || data.verified_at,
|
||||
verified_by: data['Verified By'] || data.verified_by,
|
||||
upvote_count: data['Upvote Count'] || data.upvote_count || 0,
|
||||
submitted_ip: data['Submitted IP'] || data.submitted_ip,
|
||||
created_at: data.CreatedAt || data.created_at,
|
||||
|
||||
155
influence/app/templates/email/response-verification.html
Normal file
155
influence/app/templates/email/response-verification.html
Normal file
@@ -0,0 +1,155 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Verify Response Submission</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
background-color: #ffffff;
|
||||
border-radius: 8px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.header {
|
||||
text-align: center;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 2px solid #3498db;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.header h1 {
|
||||
color: #2c3e50;
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
.content {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
.info-box {
|
||||
background-color: #f8f9fa;
|
||||
border-left: 4px solid #3498db;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.info-box strong {
|
||||
display: block;
|
||||
color: #2c3e50;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.button-container {
|
||||
text-align: center;
|
||||
margin: 30px 0;
|
||||
}
|
||||
.button {
|
||||
display: inline-block;
|
||||
padding: 12px 30px;
|
||||
margin: 10px;
|
||||
text-decoration: none;
|
||||
border-radius: 5px;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
}
|
||||
.verify-button {
|
||||
background-color: #27ae60;
|
||||
color: #ffffff;
|
||||
}
|
||||
.verify-button:hover {
|
||||
background-color: #229954;
|
||||
}
|
||||
.report-button {
|
||||
background-color: #e74c3c;
|
||||
color: #ffffff;
|
||||
}
|
||||
.report-button:hover {
|
||||
background-color: #c0392b;
|
||||
}
|
||||
.response-preview {
|
||||
background-color: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
white-space: pre-wrap;
|
||||
font-size: 14px;
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #dee2e6;
|
||||
margin-top: 30px;
|
||||
font-size: 12px;
|
||||
color: #7f8c8d;
|
||||
}
|
||||
.warning {
|
||||
background-color: #fff3cd;
|
||||
border: 1px solid #ffc107;
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
color: #856404;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>📧 Response Verification Request</h1>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<p>Dear {{REPRESENTATIVE_NAME}},</p>
|
||||
|
||||
<p>A constituent has submitted a response they claim to have received from you through the <strong>{{APP_NAME}}</strong> platform.</p>
|
||||
|
||||
<div class="info-box">
|
||||
<strong>Campaign:</strong> {{CAMPAIGN_TITLE}}
|
||||
<br>
|
||||
<strong>Response Type:</strong> {{RESPONSE_TYPE}}
|
||||
<br>
|
||||
<strong>Submitted:</strong> {{SUBMITTED_DATE}}
|
||||
<br>
|
||||
<strong>Submitted By:</strong> {{SUBMITTER_NAME}}
|
||||
</div>
|
||||
|
||||
<div class="response-preview">
|
||||
<strong>Response Content:</strong><br>
|
||||
{{RESPONSE_TEXT}}
|
||||
</div>
|
||||
|
||||
<div class="warning">
|
||||
<strong>⚠️ Action Required</strong><br>
|
||||
Please verify whether this response is authentic by clicking one of the buttons below.
|
||||
</div>
|
||||
|
||||
<div class="button-container">
|
||||
<a href="{{VERIFICATION_URL}}" class="button verify-button">
|
||||
✓ Verify This Response
|
||||
</a>
|
||||
<a href="{{REPORT_URL}}" class="button report-button">
|
||||
✗ Report as Invalid
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p><strong>Why verify?</strong> Verification helps maintain transparency and accountability in constituent communications. Verified responses appear with a special badge on the Response Wall.</p>
|
||||
|
||||
<p><strong>What happens if I report?</strong> Reported responses will be marked as disputed and may be hidden from public view while we investigate.</p>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>This email was sent by {{APP_NAME}}<br>
|
||||
You received this because a constituent submitted a response attributed to you.<br>
|
||||
Verification links expire in 30 days.</p>
|
||||
<p><strong>Timestamp:</strong> {{TIMESTAMP}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
42
influence/app/templates/email/response-verification.txt
Normal file
42
influence/app/templates/email/response-verification.txt
Normal file
@@ -0,0 +1,42 @@
|
||||
RESPONSE VERIFICATION REQUEST
|
||||
==============================
|
||||
|
||||
Dear {{REPRESENTATIVE_NAME}},
|
||||
|
||||
A constituent has submitted a response they claim to have received from you through the {{APP_NAME}} platform.
|
||||
|
||||
SUBMISSION DETAILS:
|
||||
-------------------
|
||||
Campaign: {{CAMPAIGN_TITLE}}
|
||||
Response Type: {{RESPONSE_TYPE}}
|
||||
Submitted: {{SUBMITTED_DATE}}
|
||||
Submitted By: {{SUBMITTER_NAME}}
|
||||
|
||||
RESPONSE CONTENT:
|
||||
-----------------
|
||||
{{RESPONSE_TEXT}}
|
||||
|
||||
ACTION REQUIRED:
|
||||
---------------
|
||||
Please verify whether this response is authentic by clicking one of the links below.
|
||||
|
||||
VERIFY THIS RESPONSE:
|
||||
{{VERIFICATION_URL}}
|
||||
|
||||
REPORT AS INVALID:
|
||||
{{REPORT_URL}}
|
||||
|
||||
WHY VERIFY?
|
||||
-----------
|
||||
Verification helps maintain transparency and accountability in constituent communications. Verified responses appear with a special badge on the Response Wall.
|
||||
|
||||
WHAT HAPPENS IF I REPORT?
|
||||
--------------------------
|
||||
Reported responses will be marked as disputed and may be hidden from public view while we investigate.
|
||||
|
||||
---
|
||||
This email was sent by {{APP_NAME}}
|
||||
You received this because a constituent submitted a response attributed to you.
|
||||
Verification links expire in 30 days.
|
||||
|
||||
Timestamp: {{TIMESTAMP}}
|
||||
Reference in New Issue
Block a user