diff --git a/influence/app/public/css/response-wall.css b/influence/app/public/css/response-wall.css
new file mode 100644
index 0000000..fbae3f7
--- /dev/null
+++ b/influence/app/public/css/response-wall.css
@@ -0,0 +1,359 @@
+/* Response Wall Styles */
+
+.stats-banner {
+ display: flex;
+ justify-content: space-around;
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ color: white;
+ padding: 2rem;
+ border-radius: 8px;
+ margin-bottom: 2rem;
+}
+
+.stat-item {
+ text-align: center;
+}
+
+.stat-number {
+ display: block;
+ font-size: 2.5rem;
+ font-weight: bold;
+ margin-bottom: 0.5rem;
+}
+
+.stat-label {
+ display: block;
+ font-size: 0.9rem;
+ opacity: 0.9;
+}
+
+.response-controls {
+ display: flex;
+ justify-content: space-between;
+ margin-bottom: 2rem;
+ flex-wrap: wrap;
+ gap: 1rem;
+ align-items: center;
+}
+
+.filter-group {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.filter-group label {
+ font-weight: 600;
+ margin-bottom: 0;
+}
+
+.filter-group select {
+ padding: 0.5rem;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ font-size: 1rem;
+}
+
+#submit-response-btn {
+ margin-left: auto;
+}
+
+/* Response Card */
+.response-card {
+ background: white;
+ border: 1px solid #e1e8ed;
+ border-radius: 8px;
+ padding: 1.5rem;
+ margin-bottom: 1.5rem;
+ transition: box-shadow 0.3s ease;
+}
+
+.response-card:hover {
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+}
+
+.response-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ margin-bottom: 1rem;
+}
+
+.response-rep-info {
+ flex: 1;
+}
+
+.response-rep-info h3 {
+ margin: 0 0 0.25rem 0;
+ color: #1a202c;
+ font-size: 1.2rem;
+}
+
+.response-rep-info .rep-meta {
+ color: #7f8c8d;
+ font-size: 0.9rem;
+}
+
+.response-rep-info .rep-meta span {
+ margin-right: 1rem;
+}
+
+.response-badges {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.badge {
+ padding: 0.25rem 0.75rem;
+ border-radius: 12px;
+ font-size: 0.85rem;
+ font-weight: 600;
+}
+
+.badge-verified {
+ background: #27ae60;
+ color: white;
+}
+
+.badge-level {
+ background: #3498db;
+ color: white;
+}
+
+.badge-type {
+ background: #95a5a6;
+ color: white;
+}
+
+.response-content {
+ margin-bottom: 1rem;
+}
+
+.response-text {
+ background: #f8f9fa;
+ padding: 1rem;
+ border-left: 4px solid #3498db;
+ border-radius: 4px;
+ margin-bottom: 1rem;
+ white-space: pre-wrap;
+ line-height: 1.6;
+}
+
+.user-comment {
+ padding: 0.75rem;
+ background: #fff3cd;
+ border-left: 4px solid #ffc107;
+ border-radius: 4px;
+ margin-bottom: 1rem;
+}
+
+.user-comment-label {
+ font-weight: 600;
+ color: #856404;
+ margin-bottom: 0.5rem;
+ display: block;
+}
+
+.response-screenshot {
+ margin-bottom: 1rem;
+}
+
+.response-screenshot img {
+ max-width: 100%;
+ border-radius: 4px;
+ border: 1px solid #ddd;
+ cursor: pointer;
+ transition: opacity 0.3s ease;
+}
+
+.response-screenshot img:hover {
+ opacity: 0.8;
+}
+
+.response-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding-top: 1rem;
+ border-top: 1px solid #e1e8ed;
+}
+
+.response-meta {
+ color: #7f8c8d;
+ font-size: 0.9rem;
+}
+
+.response-actions {
+ display: flex;
+ gap: 1rem;
+}
+
+.upvote-btn {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 1rem;
+ border: 2px solid #3498db;
+ background: white;
+ color: #3498db;
+ border-radius: 20px;
+ cursor: pointer;
+ transition: all 0.3s ease;
+ font-weight: 600;
+}
+
+.upvote-btn:hover {
+ background: #3498db;
+ color: white;
+ transform: translateY(-2px);
+}
+
+.upvote-btn.upvoted {
+ background: #3498db;
+ color: white;
+}
+
+.upvote-btn .upvote-icon {
+ font-size: 1.2rem;
+}
+
+.upvote-count {
+ font-weight: bold;
+}
+
+/* Empty State */
+.empty-state {
+ text-align: center;
+ padding: 3rem;
+ color: #7f8c8d;
+}
+
+.empty-state p {
+ font-size: 1.2rem;
+ margin-bottom: 1.5rem;
+}
+
+/* Modal Styles */
+.modal {
+ position: fixed;
+ z-index: 1000;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ overflow: auto;
+ background-color: rgba(0, 0, 0, 0.5);
+ display: none;
+}
+
+.modal-content {
+ background-color: white;
+ margin: 5% auto;
+ padding: 2rem;
+ border-radius: 8px;
+ max-width: 600px;
+ position: relative;
+}
+
+.close {
+ color: #aaa;
+ float: right;
+ font-size: 28px;
+ font-weight: bold;
+ cursor: pointer;
+}
+
+.close:hover,
+.close:focus {
+ color: #000;
+}
+
+.form-group {
+ margin-bottom: 1.5rem;
+}
+
+.form-group label {
+ display: block;
+ margin-bottom: 0.5rem;
+ font-weight: 600;
+ color: #1a202c;
+}
+
+.form-group input,
+.form-group select,
+.form-group textarea {
+ width: 100%;
+ padding: 0.75rem;
+ border: 1px solid #ddd;
+ border-radius: 4px;
+ font-size: 1rem;
+}
+
+.form-group small {
+ display: block;
+ margin-top: 0.25rem;
+ color: #7f8c8d;
+}
+
+.form-actions {
+ display: flex;
+ gap: 1rem;
+ margin-top: 1.5rem;
+}
+
+.form-actions .btn {
+ flex: 1;
+}
+
+.loading {
+ text-align: center;
+ padding: 2rem;
+ color: #7f8c8d;
+}
+
+.load-more-container {
+ text-align: center;
+ margin: 2rem 0;
+}
+
+/* Responsive Design */
+@media (max-width: 768px) {
+ .stats-banner {
+ flex-direction: column;
+ gap: 1.5rem;
+ }
+
+ .response-controls {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ .filter-group {
+ flex-direction: column;
+ align-items: stretch;
+ }
+
+ #submit-response-btn {
+ margin-left: 0;
+ width: 100%;
+ }
+
+ .response-header {
+ flex-direction: column;
+ }
+
+ .response-badges {
+ margin-top: 1rem;
+ }
+
+ .response-footer {
+ flex-direction: column;
+ gap: 1rem;
+ align-items: flex-start;
+ }
+
+ .modal-content {
+ margin: 10% 5%;
+ padding: 1rem;
+ }
+}
diff --git a/influence/app/public/css/styles.css b/influence/app/public/css/styles.css
index c231c81..e7ffa27 100644
--- a/influence/app/public/css/styles.css
+++ b/influence/app/public/css/styles.css
@@ -257,6 +257,10 @@ header p {
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
+ /* Prevent width changes when inline composer is added */
+ position: relative;
+ width: 100%;
+ box-sizing: border-box;
}
.rep-category h3 {
@@ -358,6 +362,169 @@ header p {
border-color: #1e7e34;
}
+/* Inline Email Composer Styles */
+.inline-email-composer {
+ margin-top: 20px;
+ padding: 20px;
+ background: white;
+ border: 2px solid #005a9c;
+ border-radius: 8px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
+ overflow: hidden;
+ max-height: 0;
+ opacity: 0;
+ transition: max-height 0.4s ease, opacity 0.3s ease, margin-top 0.4s ease, padding 0.4s ease;
+ /* Ensure it's not affected by any parent grid/flex container */
+ width: 100% !important;
+ max-width: none !important;
+ min-width: 0 !important;
+ grid-column: 1 / -1; /* Span full width if inside a grid */
+ display: block !important;
+ box-sizing: border-box !important;
+}
+
+.inline-email-composer.active {
+ max-height: 2000px;
+ opacity: 1;
+ margin-top: 20px;
+ padding: 20px;
+}
+
+.inline-email-composer.closing {
+ max-height: 0;
+ opacity: 0;
+ margin-top: 0;
+ padding: 0 20px;
+}
+
+.inline-email-composer .composer-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 20px;
+ padding-bottom: 15px;
+ border-bottom: 2px solid #e9ecef;
+}
+
+.inline-email-composer .composer-header h3 {
+ color: #005a9c;
+ margin: 0;
+ font-size: 1.3em;
+}
+
+.inline-email-composer .close-btn {
+ font-size: 24px;
+ font-weight: bold;
+ cursor: pointer;
+ color: #999;
+ transition: color 0.3s ease;
+ background: none;
+ border: none;
+ padding: 0;
+ width: 30px;
+ height: 30px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.inline-email-composer .close-btn:hover {
+ color: #333;
+}
+
+.inline-email-composer .composer-body {
+ animation: slideIn 0.3s ease;
+}
+
+@keyframes slideIn {
+ from {
+ transform: translateY(-20px);
+ opacity: 0;
+ }
+ to {
+ transform: translateY(0);
+ opacity: 1;
+ }
+}
+
+.inline-email-composer .email-preview-details {
+ background-color: #f8f9fa;
+ padding: 15px;
+ border-radius: 6px;
+ border: 1px solid #e9ecef;
+ margin-bottom: 20px;
+}
+
+.inline-email-composer .detail-row {
+ margin-bottom: 8px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.inline-email-composer .detail-row:last-child {
+ margin-bottom: 0;
+}
+
+.inline-email-composer .detail-row strong {
+ color: #495057;
+ min-width: 90px;
+ flex-shrink: 0;
+}
+
+.inline-email-composer .detail-row span {
+ color: #333;
+ word-break: break-word;
+}
+
+.inline-email-composer .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;
+}
+
+/* Wrapper for sanitized email HTML to prevent it affecting parent page */
+.inline-email-composer .email-preview-body {
+ max-width: 600px;
+ margin: 0 auto;
+ width: 100%;
+ box-sizing: border-box;
+}
+
+.inline-email-composer .email-preview-content p {
+ margin-bottom: 12px;
+}
+
+.inline-email-composer .email-preview-content p:last-child {
+ margin-bottom: 0;
+}
+
+/* Ensure all inline composer child elements respect full width */
+.inline-email-composer .composer-header,
+.inline-email-composer .composer-body,
+.inline-email-composer .recipient-info,
+.inline-email-composer .inline-email-form,
+.inline-email-composer .form-group,
+.inline-email-composer .form-actions,
+.inline-email-composer .email-preview-details,
+.inline-email-composer .email-preview-content {
+ width: 100%;
+ max-width: none;
+ box-sizing: border-box;
+}
+
+.inline-email-composer .form-group input,
+.inline-email-composer .form-group textarea {
+ width: 100%;
+ max-width: none;
+ box-sizing: border-box;
+}
+
/* Modal Styles */
.modal {
position: fixed;
diff --git a/influence/app/public/js/admin.js b/influence/app/public/js/admin.js
index 04c1b68..e56b3ce 100644
--- a/influence/app/public/js/admin.js
+++ b/influence/app/public/js/admin.js
@@ -109,6 +109,14 @@ class AdminPanel {
this.handleUserTypeChange(e.target.value);
});
}
+
+ // Response status filter
+ const responseStatusSelect = document.getElementById('admin-response-status');
+ if (responseStatusSelect) {
+ responseStatusSelect.addEventListener('change', () => {
+ this.loadAdminResponses();
+ });
+ }
}
setupFormInteractions() {
@@ -407,6 +415,8 @@ class AdminPanel {
if (this.currentCampaign) {
this.populateEditForm();
}
+ } else if (tabName === 'responses') {
+ this.loadAdminResponses();
} else if (tabName === 'users') {
this.loadUsers();
}
@@ -1051,6 +1061,231 @@ class AdminPanel {
this.showMessage('Failed to send emails: ' + error.message, 'error');
}
}
+
+ // Response Moderation Functions
+ async loadAdminResponses() {
+ const status = document.getElementById('admin-response-status').value;
+ const container = document.getElementById('admin-responses-container');
+ const loading = document.getElementById('responses-loading');
+
+ loading.classList.remove('hidden');
+ container.innerHTML = '';
+
+ try {
+ const params = new URLSearchParams({ status, limit: 100 });
+ const response = await window.apiClient.get(`/admin/responses?${params}`);
+
+ loading.classList.add('hidden');
+
+ if (response.success && response.responses.length > 0) {
+ this.renderAdminResponses(response.responses);
+ } else {
+ container.innerHTML = '
No responses found.
';
+ }
+ } catch (error) {
+ loading.classList.add('hidden');
+ console.error('Error loading responses:', error);
+ this.showMessage('Failed to load responses', 'error');
+ }
+ }
+
+ renderAdminResponses(responses) {
+ const container = document.getElementById('admin-responses-container');
+
+ console.log('Rendering admin responses:', responses.length, 'responses');
+ if (responses.length > 0) {
+ console.log('First response sample:', responses[0]);
+ }
+
+ container.innerHTML = responses.map(response => {
+ const createdDate = new Date(response.created_at).toLocaleString();
+ const statusClass = {
+ 'pending': 'warning',
+ 'approved': 'success',
+ 'rejected': 'danger'
+ }[response.status] || 'secondary';
+
+ return `
+
+
+
+
${this.escapeHtml(response.representative_name)}
+
+ ${this.escapeHtml(response.representative_level)} •
+ ${this.escapeHtml(response.response_type)} •
+ ${createdDate}
+
+
+
+
+ ${response.status.toUpperCase()}
+
+ ${response.is_verified ? '✓ VERIFIED ' : ''}
+
+
+
+
+
Response:
+
${this.escapeHtml(response.response_text)}
+
+
+ ${response.user_comment ? `
+
+
User Comment:
+
${this.escapeHtml(response.user_comment)}
+
+ ` : ''}
+
+ ${response.screenshot_url ? `
+
+
+
+ ` : ''}
+
+
+ Submitted by: ${response.is_anonymous ? 'Anonymous' : (this.escapeHtml(response.submitted_by_name) || this.escapeHtml(response.submitted_by_email) || 'Unknown')} •
+ Campaign: ${this.escapeHtml(response.campaign_slug)} •
+ Upvotes: ${response.upvote_count || 0}
+
+
+
+ ${response.status === 'pending' ? `
+ ✓ Approve
+ ✗ Reject
+ ` : ''}
+ ${response.status === 'approved' && !response.is_verified ? `
+ Mark as Verified
+ ` : ''}
+ ${response.status === 'approved' && response.is_verified ? `
+ Remove Verification
+ ` : ''}
+ ${response.status === 'rejected' ? `
+ ✓ Approve
+ ` : ''}
+ ${response.status === 'approved' ? `
+ Unpublish
+ ` : ''}
+ 🗑 Delete
+
+
+ `;
+ }).join('');
+
+ // Add event delegation for response actions
+ this.setupResponseActionListeners();
+ }
+
+ async approveResponse(id) {
+ await this.updateResponseStatus(id, 'approved');
+ }
+
+ async rejectResponse(id) {
+ await this.updateResponseStatus(id, 'rejected');
+ }
+
+ async updateResponseStatus(id, status) {
+ try {
+ const response = await window.apiClient.patch(`/admin/responses/${id}/status`, { status });
+
+ if (response.success) {
+ this.showMessage(`Response ${status} successfully!`, 'success');
+ this.loadAdminResponses();
+ } else {
+ throw new Error(response.error || 'Failed to update response status');
+ }
+ } catch (error) {
+ console.error('Error updating response status:', error);
+ this.showMessage('Failed to update response status: ' + error.message, 'error');
+ }
+ }
+
+ async toggleVerified(id, isVerified) {
+ try {
+ const response = await window.apiClient.patch(`/admin/responses/${id}`, {
+ is_verified: isVerified
+ });
+
+ if (response.success) {
+ this.showMessage(isVerified ? 'Response marked as verified!' : 'Verification removed!', 'success');
+ this.loadAdminResponses();
+ } else {
+ throw new Error(response.error || 'Failed to update verification status');
+ }
+ } catch (error) {
+ console.error('Error updating verification:', error);
+ this.showMessage('Failed to update verification: ' + error.message, 'error');
+ }
+ }
+
+ async deleteResponse(id) {
+ if (!confirm('Are you sure you want to delete this response? This action cannot be undone.')) return;
+
+ try {
+ const response = await window.apiClient.delete(`/admin/responses/${id}`);
+
+ if (response.success) {
+ this.showMessage('Response deleted successfully!', 'success');
+ this.loadAdminResponses();
+ } else {
+ throw new Error(response.error || 'Failed to delete response');
+ }
+ } catch (error) {
+ console.error('Error deleting response:', error);
+ this.showMessage('Failed to delete response: ' + error.message, 'error');
+ }
+ }
+
+ setupResponseActionListeners() {
+ const container = document.getElementById('admin-responses-container');
+ if (!container) return;
+
+ // Remove old listener if exists to avoid duplicates
+ const oldListener = container._responseActionListener;
+ if (oldListener) {
+ container.removeEventListener('click', oldListener);
+ }
+
+ // Create new listener
+ const listener = (e) => {
+ const target = e.target;
+ const action = target.dataset.action;
+ const responseId = target.dataset.responseId;
+
+ console.log('Response action clicked:', { action, responseId, target });
+
+ if (!action || !responseId) {
+ console.log('Missing action or responseId, ignoring click');
+ return;
+ }
+
+ switch (action) {
+ case 'approve-response':
+ this.approveResponse(parseInt(responseId));
+ break;
+ case 'reject-response':
+ this.rejectResponse(parseInt(responseId));
+ break;
+ case 'verify-response':
+ const isVerified = target.dataset.verified === 'true';
+ this.toggleVerified(parseInt(responseId), isVerified);
+ break;
+ case 'delete-response':
+ this.deleteResponse(parseInt(responseId));
+ break;
+ }
+ };
+
+ // Store listener reference and add it
+ container._responseActionListener = listener;
+ container.addEventListener('click', listener);
+ }
+
+ escapeHtml(text) {
+ if (!text) return '';
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+ }
}
// Initialize admin panel when DOM is loaded
diff --git a/influence/app/public/js/api-client.js b/influence/app/public/js/api-client.js
index 24a1358..23f709a 100644
--- a/influence/app/public/js/api-client.js
+++ b/influence/app/public/js/api-client.js
@@ -45,6 +45,26 @@ class APIClient {
});
}
+ async put(endpoint, data) {
+ return this.makeRequest(endpoint, {
+ method: 'PUT',
+ body: JSON.stringify(data)
+ });
+ }
+
+ async patch(endpoint, data) {
+ return this.makeRequest(endpoint, {
+ method: 'PATCH',
+ body: JSON.stringify(data)
+ });
+ }
+
+ async delete(endpoint) {
+ return this.makeRequest(endpoint, {
+ method: 'DELETE'
+ });
+ }
+
async postFormData(endpoint, formData) {
// Don't set Content-Type header - browser will set it with boundary for multipart/form-data
const config = {
diff --git a/influence/app/public/js/email-composer.js b/influence/app/public/js/email-composer.js
index 0940bac..eef834f 100644
--- a/influence/app/public/js/email-composer.js
+++ b/influence/app/public/js/email-composer.js
@@ -16,6 +16,8 @@ class EmailComposer {
this.currentRecipient = null;
this.currentEmailData = null;
this.lastPreviewTime = 0; // Track last preview request time
+ this.currentInlineComposer = null; // Track currently open inline composer
+ this.currentRepCard = null; // Track which rep card has the composer open
this.init();
}
@@ -67,7 +69,533 @@ class EmailComposer {
});
}
- openModal(recipient) {
+ openModal(recipient, repCard = null) {
+ // Close any existing inline composers first
+ this.closeInlineComposer();
+
+ this.currentRecipient = recipient;
+ this.currentRepCard = repCard;
+
+ // If repCard is provided, create inline composer
+ if (repCard) {
+ this.createInlineComposer(repCard, recipient);
+ return;
+ }
+
+ // Otherwise, use modal (fallback)
+ this.openModalDialog(recipient);
+ }
+
+ createInlineComposer(repCard, recipient) {
+ // Explicitly hide old modals
+ if (this.modal) this.modal.style.display = 'none';
+ if (this.previewModal) this.previewModal.style.display = 'none';
+
+ // Create the inline composer HTML
+ const composerHTML = this.getComposerHTML(recipient);
+
+ // Create a container div
+ const composerDiv = document.createElement('div');
+ composerDiv.className = 'inline-email-composer';
+ composerDiv.innerHTML = composerHTML;
+
+ // Find the parent category container (not the grid)
+ // The structure is: rep-category > rep-cards (grid) > rep-card
+ const repCardsGrid = repCard.parentElement; // The grid container
+ const repCategory = repCardsGrid.parentElement; // The category container
+
+ // Insert after the grid, not inside it
+ repCategory.insertAdjacentElement('beforeend', composerDiv);
+ this.currentInlineComposer = composerDiv;
+
+ // Trigger animation after a small delay
+ setTimeout(() => {
+ composerDiv.classList.add('active');
+ }, 10);
+
+ // Attach event listeners to the inline form
+ this.attachInlineFormListeners(composerDiv);
+
+ // Focus on first input
+ setTimeout(() => {
+ const firstInput = composerDiv.querySelector('#inline-sender-name');
+ if (firstInput) firstInput.focus();
+ }, 400);
+
+ // Scroll the composer into view
+ setTimeout(() => {
+ composerDiv.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
+ }, 100);
+ }
+
+ getComposerHTML(recipient) {
+ const postalCode = window.postalLookup ? window.postalLookup.currentPostalCode : '';
+ const defaultSubject = `Message from your constituent from ${postalCode}`;
+
+ return `
+
+
+
+ ${recipient.name}
+ ${recipient.office}
+ ${recipient.district}
+ ${recipient.email}
+
+
+
+
+ `;
+ }
+
+ attachInlineFormListeners(composerDiv) {
+ // Close button
+ const closeBtn = composerDiv.querySelector('.inline-close');
+ closeBtn.addEventListener('click', () => this.closeInlineComposer());
+
+ // Cancel button
+ const cancelBtn = composerDiv.querySelector('.inline-cancel');
+ cancelBtn.addEventListener('click', () => this.closeInlineComposer());
+
+ // Form submission
+ const form = composerDiv.querySelector('.inline-email-form');
+ form.addEventListener('submit', (e) => this.handleInlinePreview(e, composerDiv));
+
+ // Character counter
+ const messageTextarea = composerDiv.querySelector('#inline-email-message');
+ const charCounter = composerDiv.querySelector('.inline-char-counter');
+ messageTextarea.addEventListener('input', () => {
+ const maxLength = 5000;
+ const currentLength = messageTextarea.value.length;
+ const remaining = maxLength - currentLength;
+ charCounter.textContent = `${remaining} characters remaining`;
+ if (remaining < 100) {
+ charCounter.style.color = '#d9534f';
+ } else {
+ charCounter.style.color = '#666';
+ }
+ });
+
+ // Update subject with sender name
+ const senderNameField = composerDiv.querySelector('#inline-sender-name');
+ const subjectField = composerDiv.querySelector('#inline-email-subject');
+ const postalCodeField = composerDiv.querySelector('#inline-sender-postal-code');
+
+ senderNameField.addEventListener('input', () => {
+ const senderName = senderNameField.value.trim();
+ const postalCode = postalCodeField.value.trim();
+ if (senderName && postalCode) {
+ subjectField.value = `Message from ${senderName} - ${postalCode}`;
+ }
+ });
+ }
+
+ closeInlineComposer() {
+ if (this.currentInlineComposer) {
+ // Add closing class for animation
+ this.currentInlineComposer.classList.remove('active');
+ this.currentInlineComposer.classList.add('closing');
+
+ // Remove from DOM after animation
+ setTimeout(() => {
+ if (this.currentInlineComposer && this.currentInlineComposer.parentNode) {
+ this.currentInlineComposer.parentNode.removeChild(this.currentInlineComposer);
+ }
+ this.currentInlineComposer = null;
+ this.currentRepCard = null;
+ }, 400);
+ }
+ }
+
+ async handleInlinePreview(e, composerDiv) {
+ e.preventDefault();
+
+ // Prevent duplicate calls
+ const currentTime = Date.now();
+ if (currentTime - this.lastPreviewTime < 2000) {
+ return;
+ }
+ this.lastPreviewTime = currentTime;
+
+ // Get form data from inline form
+ const formData = this.getInlineFormData(composerDiv);
+ const errors = this.validateInlineForm(formData);
+
+ if (errors.length > 0) {
+ window.messageDisplay.show(errors.join('
'), 'error');
+ return;
+ }
+
+ const submitButton = composerDiv.querySelector('button[type="submit"]');
+ const originalText = submitButton.textContent;
+
+ try {
+ submitButton.disabled = true;
+ submitButton.textContent = 'Loading preview...';
+
+ // Store form data for later use - match API expected field names
+ this.currentEmailData = {
+ recipientEmail: formData.recipientEmail,
+ senderName: formData.senderName,
+ senderEmail: formData.senderEmail,
+ postalCode: formData.postalCode, // API expects 'postalCode' not 'senderPostalCode'
+ subject: formData.subject,
+ message: formData.message
+ };
+
+ // Get preview from backend
+ const response = await window.apiClient.previewEmail(this.currentEmailData);
+
+ if (response.success) {
+ // Replace inline composer with inline preview
+ this.showInlinePreview(response.preview, composerDiv);
+ } else {
+ window.messageDisplay.show(response.error || 'Failed to generate preview', 'error');
+ }
+ } catch (error) {
+ console.error('Preview error:', error);
+ window.messageDisplay.show('Failed to generate email preview. Please try again.', 'error');
+ } finally {
+ submitButton.disabled = false;
+ submitButton.textContent = originalText;
+ }
+ }
+
+ getInlineFormData(composerDiv) {
+ return {
+ recipientEmail: composerDiv.querySelector('#inline-recipient-email').value.trim(),
+ senderName: composerDiv.querySelector('#inline-sender-name').value.trim(),
+ senderEmail: composerDiv.querySelector('#inline-sender-email').value.trim(),
+ postalCode: composerDiv.querySelector('#inline-sender-postal-code').value.trim(),
+ subject: composerDiv.querySelector('#inline-email-subject').value.trim(),
+ message: composerDiv.querySelector('#inline-email-message').value.trim()
+ };
+ }
+
+ validateInlineForm(formData) {
+ const errors = [];
+
+ if (!formData.senderName) {
+ errors.push('Please enter your name');
+ }
+
+ if (!formData.senderEmail) {
+ errors.push('Please enter your email');
+ } else if (!this.validateEmail(formData.senderEmail)) {
+ errors.push('Please enter a valid email address');
+ }
+
+ if (!formData.subject) {
+ errors.push('Please enter a subject');
+ }
+
+ if (!formData.message) {
+ errors.push('Please enter a message');
+ } else if (formData.message.length < 10) {
+ errors.push('Message must be at least 10 characters long');
+ }
+
+ // Validate postal code format if provided (required by API)
+ if (!formData.postalCode || formData.postalCode.trim() === '') {
+ errors.push('Postal code is required');
+ } else {
+ // Check postal code format: A1A 1A1 or A1A1A1
+ const postalCodePattern = /^[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d$/;
+ if (!postalCodePattern.test(formData.postalCode)) {
+ errors.push('Please enter a valid Canadian postal code (e.g., T5N 4B8)');
+ }
+ }
+
+ // Check for suspicious content
+ if (this.containsSuspiciousContent(formData.message) || this.containsSuspiciousContent(formData.subject)) {
+ errors.push('Message contains potentially malicious content');
+ }
+
+ return errors;
+ }
+
+ showInlinePreview(preview, composerDiv) {
+ // Ensure old preview modal is hidden
+ if (this.previewModal) {
+ this.previewModal.style.display = 'none';
+ }
+
+ // Replace the composer content with preview content
+ const previewHTML = this.getPreviewHTML(preview);
+
+ // Fade out composer body
+ const composerBody = composerDiv.querySelector('.composer-body');
+ composerBody.style.opacity = '0';
+ composerBody.style.transition = 'opacity 0.2s ease';
+
+ setTimeout(() => {
+ composerBody.innerHTML = previewHTML;
+ composerBody.style.opacity = '1';
+
+ // Attach event listeners to preview buttons
+ this.attachPreviewListeners(composerDiv);
+
+ // Update header
+ const header = composerDiv.querySelector('.composer-header h3');
+ header.textContent = 'Email Preview';
+
+ // Scroll to make sure preview is visible
+ setTimeout(() => {
+ composerDiv.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
+ }, 100);
+ }, 200);
+ }
+
+ getPreviewHTML(preview) {
+ const html = preview.html || '';
+ const testModeWarning = preview.testMode ?
+ `
+ TEST MODE: Email will be redirected to ${this.escapeHtml(preview.redirectTo)}
+
` : '';
+
+ // Sanitize the HTML to remove any `;
+ });
+
+ // Wrap the sanitized content in a container div
+ return `
${sanitized}
`;
+ }
+
+ attachPreviewListeners(composerDiv) {
+ // Edit button - go back to composer
+ const editBtn = composerDiv.querySelector('.preview-edit');
+ editBtn.addEventListener('click', () => {
+ this.returnToComposer(composerDiv);
+ });
+
+ // Send button - send the email
+ const sendBtn = composerDiv.querySelector('.preview-send');
+ sendBtn.addEventListener('click', async () => {
+ await this.sendFromInlinePreview(sendBtn, composerDiv);
+ });
+ }
+
+ returnToComposer(composerDiv) {
+ // Restore the composer form with the current data
+ const composerFormHTML = this.getComposerFormHTML();
+
+ const composerBody = composerDiv.querySelector('.composer-body');
+ composerBody.style.opacity = '0';
+
+ setTimeout(() => {
+ composerBody.innerHTML = composerFormHTML;
+
+ // Re-populate form fields with saved data
+ if (this.currentEmailData) {
+ composerDiv.querySelector('#inline-sender-name').value = this.currentEmailData.senderName || '';
+ composerDiv.querySelector('#inline-sender-email').value = this.currentEmailData.senderEmail || '';
+ composerDiv.querySelector('#inline-sender-postal-code').value = this.currentEmailData.postalCode || '';
+ composerDiv.querySelector('#inline-email-subject').value = this.currentEmailData.subject || '';
+ composerDiv.querySelector('#inline-email-message').value = this.currentEmailData.message || '';
+
+ // Update character counter
+ const messageField = composerDiv.querySelector('#inline-email-message');
+ const charCounter = composerDiv.querySelector('.inline-char-counter');
+ const remaining = 5000 - messageField.value.length;
+ charCounter.textContent = `${remaining} characters remaining`;
+ }
+
+ composerBody.style.opacity = '1';
+
+ // Re-attach form listeners
+ this.attachInlineFormListeners(composerDiv);
+
+ // Update header
+ const header = composerDiv.querySelector('.composer-header h3');
+ header.textContent = `Compose Email to ${this.currentRecipient.name}`;
+ }, 200);
+ }
+
+ getComposerFormHTML() {
+ const recipient = this.currentRecipient;
+ return `
+
+ ${recipient.name}
+ ${recipient.office}
+ ${recipient.district}
+ ${recipient.email}
+
+
+
+
+
+
+ Your Name *
+
+
+
+
+ Your Email *
+
+
+
+
+ Your Postal Code *
+
+
+
+
+ Subject *
+
+
+
+
+ Your Message *
+
+ 5000 characters remaining
+
+
+
+ Cancel
+ Preview & Send
+
+
+ `;
+ }
+
+ async sendFromInlinePreview(sendBtn, composerDiv) {
+ if (!this.currentEmailData) {
+ window.messageDisplay.show('No email data to send', 'error');
+ return;
+ }
+
+ const originalText = sendBtn.textContent;
+
+ try {
+ sendBtn.disabled = true;
+ sendBtn.textContent = 'Sending...';
+
+ const result = await window.apiClient.sendEmail(this.currentEmailData);
+
+ if (result.success) {
+ window.messageDisplay.show('Email sent successfully! Your representative will receive your message.', 'success', 7000);
+
+ // Close the inline composer after successful send
+ this.closeInlineComposer();
+ this.currentEmailData = null;
+ this.currentRecipient = null;
+ } else {
+ throw new Error(result.message || 'Failed to send email');
+ }
+
+ } catch (error) {
+ console.error('Email send failed:', error);
+
+ // Handle rate limit errors specifically
+ if (error.message && error.message.includes('rate limit')) {
+ window.messageDisplay.show('You are sending emails too quickly. Please wait a few minutes and try again.', 'error', 8000);
+ } else {
+ window.messageDisplay.show(`Failed to send email: ${error.message}`, 'error', 6000);
+ }
+ } finally {
+ sendBtn.disabled = false;
+ sendBtn.textContent = originalText;
+ }
+ }
+
+ openModalDialog(recipient) {
this.currentRecipient = recipient;
// Populate recipient info
diff --git a/influence/app/public/js/representatives-display.js b/influence/app/public/js/representatives-display.js
index 383f8fc..4f9894e 100644
--- a/influence/app/public/js/representatives-display.js
+++ b/influence/app/public/js/representatives-display.js
@@ -354,43 +354,49 @@ class RepresentativesDisplay {
}
attachEventListeners() {
- // Add event listeners for compose email buttons
+ // Email compose buttons
const composeButtons = this.container.querySelectorAll('.compose-email');
composeButtons.forEach(button => {
button.addEventListener('click', (e) => {
- const email = e.target.dataset.email;
- const name = e.target.dataset.name;
- const office = e.target.dataset.office;
- const district = e.target.dataset.district;
+ e.preventDefault();
+ const email = button.dataset.email;
+ const name = button.dataset.name;
+ const office = button.dataset.office;
+ const district = button.dataset.district;
- window.emailComposer.openModal({
- email,
- name,
- office,
- district
- });
+ // Find the closest rep-card ancestor
+ const repCard = button.closest('.rep-card');
+
+ if (window.emailComposer) {
+ window.emailComposer.openModal({
+ email,
+ name,
+ office,
+ district
+ }, repCard); // Pass the card element
+ }
});
});
-
- // Add event listeners for call buttons
+
+ // Call buttons
const callButtons = this.container.querySelectorAll('.call-representative');
callButtons.forEach(button => {
button.addEventListener('click', (e) => {
- const phone = e.target.dataset.phone;
- const name = e.target.dataset.name;
- const office = e.target.dataset.office;
- const officeType = e.target.dataset.officeType;
+ e.preventDefault();
+ const phone = button.dataset.phone;
+ const name = button.dataset.name;
+ const office = button.dataset.office;
+ const officeType = button.dataset.officeType || '';
this.handleCallClick(phone, name, office, officeType);
});
});
- // Add event listeners for visit buttons
+ // Visit buttons (for office addresses)
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;
@@ -398,15 +404,14 @@ class RepresentativesDisplay {
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')) {
+
+ // Photo error handling (fallback to initials)
+ const photos = this.container.querySelectorAll('.rep-photo img');
+ photos.forEach(img => {
+ img.addEventListener('error', function() {
+ this.style.display = 'none';
+ const fallback = this.parentElement.querySelector('.rep-photo-fallback');
+ if (fallback) {
fallback.style.display = 'flex';
}
});
diff --git a/influence/app/public/js/response-wall.js b/influence/app/public/js/response-wall.js
new file mode 100644
index 0000000..9a60d7b
--- /dev/null
+++ b/influence/app/public/js/response-wall.js
@@ -0,0 +1,365 @@
+// Response Wall JavaScript
+
+let currentCampaignSlug = null;
+let currentOffset = 0;
+let currentSort = 'recent';
+let currentLevel = '';
+const LIMIT = 20;
+
+// Initialize
+document.addEventListener('DOMContentLoaded', () => {
+ console.log('Response Wall: Initializing...');
+
+ // Get campaign slug from URL if present
+ const urlParams = new URLSearchParams(window.location.search);
+ currentCampaignSlug = urlParams.get('campaign');
+
+ console.log('Campaign slug:', currentCampaignSlug);
+
+ if (!currentCampaignSlug) {
+ showError('No campaign specified');
+ return;
+ }
+
+ // Load initial data
+ loadResponseStats();
+ loadResponses(true);
+
+ // Set up event listeners
+ document.getElementById('sort-select').addEventListener('change', (e) => {
+ currentSort = e.target.value;
+ loadResponses(true);
+ });
+
+ document.getElementById('level-filter').addEventListener('change', (e) => {
+ currentLevel = e.target.value;
+ loadResponses(true);
+ });
+
+ const submitBtn = document.getElementById('submit-response-btn');
+ console.log('Submit button found:', !!submitBtn);
+ if (submitBtn) {
+ submitBtn.addEventListener('click', () => {
+ console.log('Submit button clicked');
+ openSubmitModal();
+ });
+ }
+
+ // Use event delegation for empty state button since it's dynamically shown
+ document.addEventListener('click', (e) => {
+ if (e.target.id === 'empty-state-submit-btn') {
+ console.log('Empty state button clicked');
+ openSubmitModal();
+ }
+ });
+
+ const modalCloseBtn = document.getElementById('modal-close-btn');
+ if (modalCloseBtn) {
+ modalCloseBtn.addEventListener('click', closeSubmitModal);
+ }
+
+ const cancelBtn = document.getElementById('cancel-submit-btn');
+ if (cancelBtn) {
+ cancelBtn.addEventListener('click', closeSubmitModal);
+ }
+
+ const loadMoreBtn = document.getElementById('load-more-btn');
+ if (loadMoreBtn) {
+ loadMoreBtn.addEventListener('click', loadMoreResponses);
+ }
+
+ const form = document.getElementById('submit-response-form');
+ if (form) {
+ form.addEventListener('submit', handleSubmitResponse);
+ }
+
+ console.log('Response Wall: Initialization complete');
+});
+
+// Load response statistics
+async function loadResponseStats() {
+ try {
+ const response = await fetch(`/api/campaigns/${currentCampaignSlug}/response-stats`);
+ const data = await response.json();
+
+ if (data.success) {
+ document.getElementById('stat-total-responses').textContent = data.stats.totalResponses;
+ document.getElementById('stat-verified').textContent = data.stats.verifiedResponses;
+ document.getElementById('stat-upvotes').textContent = data.stats.totalUpvotes;
+ document.getElementById('stats-banner').style.display = 'flex';
+ }
+ } catch (error) {
+ console.error('Error loading stats:', error);
+ }
+}
+
+// Load responses
+async function loadResponses(reset = false) {
+ if (reset) {
+ currentOffset = 0;
+ document.getElementById('responses-container').innerHTML = '';
+ }
+
+ showLoading(true);
+
+ try {
+ const params = new URLSearchParams({
+ sort: currentSort,
+ level: currentLevel,
+ offset: currentOffset,
+ limit: LIMIT
+ });
+
+ const response = await fetch(`/api/campaigns/${currentCampaignSlug}/responses?${params}`);
+ const data = await response.json();
+
+ showLoading(false);
+
+ if (data.success && data.responses.length > 0) {
+ renderResponses(data.responses);
+
+ // Show/hide load more button
+ if (data.pagination.hasMore) {
+ document.getElementById('load-more-container').style.display = 'block';
+ } else {
+ document.getElementById('load-more-container').style.display = 'none';
+ }
+ } else if (reset) {
+ showEmptyState();
+ }
+ } catch (error) {
+ showLoading(false);
+ showError('Failed to load responses');
+ console.error('Error loading responses:', error);
+ }
+}
+
+// Render responses
+function renderResponses(responses) {
+ const container = document.getElementById('responses-container');
+
+ responses.forEach(response => {
+ const card = createResponseCard(response);
+ container.appendChild(card);
+ });
+}
+
+// Create response card element
+function createResponseCard(response) {
+ const card = document.createElement('div');
+ card.className = 'response-card';
+ card.dataset.responseId = response.id;
+
+ const createdDate = new Date(response.created_at).toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric'
+ });
+
+ let badges = `
${escapeHtml(response.representative_level)} `;
+ badges += `
${escapeHtml(response.response_type)} `;
+ if (response.is_verified) {
+ badges = `
✓ Verified ` + badges;
+ }
+
+ let submittedBy = 'Anonymous';
+ if (!response.is_anonymous && response.submitted_by_name) {
+ submittedBy = escapeHtml(response.submitted_by_name);
+ }
+
+ let userCommentHtml = '';
+ if (response.user_comment) {
+ userCommentHtml = `
+
+ `;
+ }
+
+ let screenshotHtml = '';
+ if (response.screenshot_url) {
+ screenshotHtml = `
+
+
+
+ `;
+ }
+
+ const upvoteClass = response.hasUpvoted ? 'upvoted' : '';
+
+ card.innerHTML = `
+
+
+
+
${escapeHtml(response.response_text)}
+ ${userCommentHtml}
+ ${screenshotHtml}
+
+
+
+ `;
+
+ // Add event listener for upvote button
+ const upvoteBtn = card.querySelector('.upvote-btn');
+ upvoteBtn.addEventListener('click', function() {
+ toggleUpvote(response.id, this);
+ });
+
+ // Add event listener for screenshot image if present
+ const screenshotImg = card.querySelector('.screenshot-image');
+ if (screenshotImg) {
+ screenshotImg.addEventListener('click', function() {
+ viewImage(this.dataset.imageUrl);
+ });
+ }
+
+ return card;
+}
+
+// Toggle upvote
+async function toggleUpvote(responseId, button) {
+ const isUpvoted = button.classList.contains('upvoted');
+ const url = `/api/responses/${responseId}/upvote`;
+
+ try {
+ const response = await fetch(url, {
+ method: isUpvoted ? 'DELETE' : 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ }
+ });
+
+ const data = await response.json();
+
+ if (data.success) {
+ // Update button state
+ button.classList.toggle('upvoted');
+ button.querySelector('.upvote-count').textContent = data.upvoteCount;
+
+ // Reload stats
+ loadResponseStats();
+ } else {
+ showError(data.error || 'Failed to update upvote');
+ }
+ } catch (error) {
+ console.error('Error toggling upvote:', error);
+ showError('Failed to update upvote');
+ }
+}
+
+// Load more responses
+function loadMoreResponses() {
+ currentOffset += LIMIT;
+ loadResponses(false);
+}
+
+// Open submit modal
+function openSubmitModal() {
+ console.log('openSubmitModal called');
+ const modal = document.getElementById('submit-modal');
+ if (modal) {
+ modal.style.display = 'block';
+ console.log('Modal displayed');
+ } else {
+ console.error('Modal element not found');
+ }
+}
+
+// Close submit modal
+function closeSubmitModal() {
+ document.getElementById('submit-modal').style.display = 'none';
+ document.getElementById('submit-response-form').reset();
+}
+
+// Handle response submission
+async function handleSubmitResponse(e) {
+ e.preventDefault();
+
+ const formData = new FormData(e.target);
+
+ try {
+ const response = await fetch(`/api/campaigns/${currentCampaignSlug}/responses`, {
+ method: 'POST',
+ body: formData
+ });
+
+ const data = await response.json();
+
+ if (data.success) {
+ showSuccess(data.message || 'Response submitted successfully! It will appear after moderation.');
+ closeSubmitModal();
+ // Don't reload responses since submission is pending approval
+ } else {
+ showError(data.error || 'Failed to submit response');
+ }
+ } catch (error) {
+ console.error('Error submitting response:', error);
+ showError('Failed to submit response');
+ }
+}
+
+// View image in modal/new tab
+function viewImage(url) {
+ window.open(url, '_blank');
+}
+
+// Utility functions
+function showLoading(show) {
+ document.getElementById('loading').style.display = show ? 'block' : 'none';
+}
+
+function showEmptyState() {
+ document.getElementById('empty-state').style.display = 'block';
+ document.getElementById('responses-container').innerHTML = '';
+ document.getElementById('load-more-container').style.display = 'none';
+}
+
+function showError(message) {
+ // Simple alert for now - could integrate with existing error display system
+ alert('Error: ' + message);
+}
+
+function showSuccess(message) {
+ // Simple alert for now - could integrate with existing success display system
+ alert(message);
+}
+
+function escapeHtml(text) {
+ if (!text) return '';
+ const div = document.createElement('div');
+ div.textContent = text;
+ return div.innerHTML;
+}
+
+// Close modal when clicking outside
+window.onclick = function(event) {
+ const modal = document.getElementById('submit-modal');
+ if (event.target === modal) {
+ closeSubmitModal();
+ }
+};
diff --git a/influence/app/public/response-wall.html b/influence/app/public/response-wall.html
new file mode 100644
index 0000000..005cf64
--- /dev/null
+++ b/influence/app/public/response-wall.html
@@ -0,0 +1,163 @@
+
+
+
+
+
+
Response Wall | BNKops Influence
+
+
+
+
+
+
+
+
+
+
+ 0
+ Total Responses
+
+
+ 0
+ Verified
+
+
+ 0
+ Total Upvotes
+
+
+
+
+
+
+ Sort by:
+
+ Most Recent
+ Most Upvoted
+ Verified First
+
+
+
+
+ Filter by Level:
+
+ All Levels
+ Federal
+ Provincial
+ Municipal
+ School Board
+
+
+
+
+ ✍️ Share a Response
+
+
+
+
+
+
+
+
+
No responses yet. Be the first to share!
+
Share a Response
+
+
+
+
+
+
+
+ Load More
+
+
+
+
+
+
+
+
+
diff --git a/influence/app/routes/api.js b/influence/app/routes/api.js
index 903276a..8ef3c22 100644
--- a/influence/app/routes/api.js
+++ b/influence/app/routes/api.js
@@ -4,8 +4,10 @@ const { body, param, validationResult } = require('express-validator');
const representativesController = require('../controllers/representatives');
const emailsController = require('../controllers/emails');
const campaignsController = require('../controllers/campaigns');
+const responsesController = require('../controllers/responses');
const rateLimiter = require('../utils/rate-limiter');
-const { requireAdmin, requireAuth, requireNonTemp } = require('../middleware/auth');
+const { requireAdmin, requireAuth, requireNonTemp, optionalAuth } = require('../middleware/auth');
+const upload = require('../middleware/upload');
// Import user routes
const userRoutes = require('./users');
@@ -192,4 +194,51 @@ router.post(
// User management routes (admin only)
router.use('/admin/users', userRoutes);
+// Response Wall Routes
+router.get('/campaigns/:slug/responses', rateLimiter.general, responsesController.getCampaignResponses);
+router.get('/campaigns/:slug/response-stats', rateLimiter.general, responsesController.getResponseStats);
+router.post(
+ '/campaigns/:slug/responses',
+ optionalAuth,
+ upload.single('screenshot'),
+ rateLimiter.general,
+ [
+ body('representative_name').notEmpty().withMessage('Representative name is required'),
+ body('representative_level').isIn(['Federal', 'Provincial', 'Municipal', 'School Board']).withMessage('Invalid representative level'),
+ body('response_type').isIn(['Email', 'Letter', 'Phone Call', 'Meeting', 'Social Media', 'Other']).withMessage('Invalid response type'),
+ body('response_text').notEmpty().withMessage('Response text is required')
+ ],
+ handleValidationErrors,
+ responsesController.submitResponse
+);
+router.post('/responses/:id/upvote', optionalAuth, rateLimiter.general, responsesController.upvoteResponse);
+router.delete('/responses/:id/upvote', optionalAuth, rateLimiter.general, responsesController.removeUpvote);
+
+// Admin Response Management Routes
+router.get('/admin/responses', requireAdmin, rateLimiter.general, responsesController.getAdminResponses);
+router.patch('/admin/responses/:id/status', requireAdmin, rateLimiter.general,
+ [body('status').isIn(['pending', 'approved', 'rejected']).withMessage('Invalid status')],
+ handleValidationErrors,
+ responsesController.updateResponseStatus
+);
+router.patch('/admin/responses/:id', requireAdmin, rateLimiter.general, responsesController.updateResponse);
+router.delete('/admin/responses/:id', requireAdmin, rateLimiter.general, responsesController.deleteResponse);
+
+// Debug endpoint to check raw NocoDB data
+router.get('/debug/responses', requireAdmin, async (req, res) => {
+ try {
+ const nocodbService = require('../services/nocodb');
+ // Get raw data without normalization
+ const rawResult = await nocodbService.getAll(nocodbService.tableIds.representativeResponses, {});
+ res.json({
+ success: true,
+ count: rawResult.list?.length || 0,
+ rawResponses: rawResult.list || [],
+ normalized: await nocodbService.getRepresentativeResponses({})
+ });
+ } catch (error) {
+ res.status(500).json({ error: error.message, stack: error.stack });
+ }
+});
+
module.exports = router;
\ No newline at end of file
diff --git a/influence/app/services/nocodb.js b/influence/app/services/nocodb.js
index ea7a593..59f67ad 100644
--- a/influence/app/services/nocodb.js
+++ b/influence/app/services/nocodb.js
@@ -26,7 +26,9 @@ class NocoDBService {
campaigns: process.env.NOCODB_TABLE_CAMPAIGNS,
campaignEmails: process.env.NOCODB_TABLE_CAMPAIGN_EMAILS,
users: process.env.NOCODB_TABLE_USERS,
- calls: process.env.NOCODB_TABLE_CALLS
+ calls: process.env.NOCODB_TABLE_CALLS,
+ representativeResponses: process.env.NOCODB_TABLE_REPRESENTATIVE_RESPONSES,
+ responseUpvotes: process.env.NOCODB_TABLE_RESPONSE_UPVOTES
};
// Validate that all table IDs are set
@@ -688,6 +690,191 @@ class NocoDBService {
return await this.getAll(this.tableIds.users, params);
}
+
+ // Representative Responses methods
+ async getRepresentativeResponses(params = {}) {
+ if (!this.tableIds.representativeResponses) {
+ throw new Error('Representative responses table not configured');
+ }
+ console.log('getRepresentativeResponses params:', JSON.stringify(params, null, 2));
+ const result = await this.getAll(this.tableIds.representativeResponses, params);
+
+ // Log without the where clause to see ALL responses
+ if (params.where) {
+ const allResult = await this.getAll(this.tableIds.representativeResponses, {});
+ console.log(`Total responses in DB (no filter): ${allResult.list?.length || 0}`);
+ if (allResult.list && allResult.list.length > 0) {
+ console.log('Sample raw response from DB:', JSON.stringify(allResult.list[0], null, 2));
+ }
+ }
+
+ console.log('getRepresentativeResponses raw result:', JSON.stringify(result, null, 2));
+ // NocoDB returns {list: [...]} or {pageInfo: {...}, list: [...]}
+ const list = result.list || [];
+ console.log(`getRepresentativeResponses: Found ${list.length} responses`);
+ return list.map(item => this.normalizeResponse(item));
+ }
+
+ async getRepresentativeResponseById(responseId) {
+ if (!this.tableIds.representativeResponses) {
+ throw new Error('Representative responses table not configured');
+ }
+ try {
+ const url = `${this.getTableUrl(this.tableIds.representativeResponses)}/${responseId}`;
+ const response = await this.client.get(url);
+ return this.normalizeResponse(response.data);
+ } catch (error) {
+ console.error('Error getting representative response by ID:', error);
+ throw error;
+ }
+ }
+
+ async createRepresentativeResponse(responseData) {
+ if (!this.tableIds.representativeResponses) {
+ throw new Error('Representative responses table not configured');
+ }
+
+ // Ensure campaign_id is not null/undefined
+ if (!responseData.campaign_id) {
+ throw new Error('Campaign ID is required for creating a response');
+ }
+
+ const data = {
+ 'Campaign ID': responseData.campaign_id,
+ 'Campaign Slug': responseData.campaign_slug,
+ 'Representative Name': responseData.representative_name,
+ 'Representative Title': responseData.representative_title,
+ 'Representative Level': responseData.representative_level,
+ 'Response Type': responseData.response_type,
+ 'Response Text': responseData.response_text,
+ 'User Comment': responseData.user_comment,
+ 'Screenshot URL': responseData.screenshot_url,
+ 'Submitted By Name': responseData.submitted_by_name,
+ 'Submitted By Email': responseData.submitted_by_email,
+ 'Submitted By User ID': responseData.submitted_by_user_id,
+ 'Is Anonymous': responseData.is_anonymous,
+ 'Status': responseData.status,
+ 'Is Verified': responseData.is_verified,
+ 'Upvote Count': responseData.upvote_count,
+ 'Submitted IP': responseData.submitted_ip
+ };
+
+ console.log('Creating response with data:', JSON.stringify(data, null, 2));
+
+ const url = this.getTableUrl(this.tableIds.representativeResponses);
+ const response = await this.client.post(url, data);
+ return this.normalizeResponse(response.data);
+ }
+
+ async updateRepresentativeResponse(responseId, updates) {
+ if (!this.tableIds.representativeResponses) {
+ throw new Error('Representative responses table not configured');
+ }
+
+ const data = {};
+ if (updates.status !== undefined) data['Status'] = updates.status;
+ if (updates.is_verified !== undefined) data['Is Verified'] = updates.is_verified;
+ if (updates.upvote_count !== undefined) data['Upvote Count'] = updates.upvote_count;
+ if (updates.response_text !== undefined) data['Response Text'] = updates.response_text;
+ if (updates.user_comment !== undefined) data['User Comment'] = updates.user_comment;
+
+ console.log(`Updating response ${responseId} with data:`, JSON.stringify(data, null, 2));
+
+ const url = `${this.getTableUrl(this.tableIds.representativeResponses)}/${responseId}`;
+ const response = await this.client.patch(url, data);
+
+ console.log('NocoDB update response:', JSON.stringify(response.data, null, 2));
+
+ return this.normalizeResponse(response.data);
+ }
+
+ async deleteRepresentativeResponse(responseId) {
+ if (!this.tableIds.representativeResponses) {
+ throw new Error('Representative responses table not configured');
+ }
+ const url = `${this.getTableUrl(this.tableIds.representativeResponses)}/${responseId}`;
+ const response = await this.client.delete(url);
+ return response.data;
+ }
+
+ // Response Upvotes methods
+ async getResponseUpvotes(params = {}) {
+ if (!this.tableIds.responseUpvotes) {
+ throw new Error('Response upvotes table not configured');
+ }
+ const result = await this.getAll(this.tableIds.responseUpvotes, params);
+ // NocoDB returns {list: [...]} or {pageInfo: {...}, list: [...]}
+ const list = result.list || [];
+ return list.map(item => this.normalizeUpvote(item));
+ }
+
+ async createResponseUpvote(upvoteData) {
+ if (!this.tableIds.responseUpvotes) {
+ throw new Error('Response upvotes table not configured');
+ }
+
+ const data = {
+ 'Response ID': upvoteData.response_id,
+ 'User ID': upvoteData.user_id,
+ 'User Email': upvoteData.user_email,
+ 'Upvoted IP': upvoteData.upvoted_ip
+ };
+
+ const url = this.getTableUrl(this.tableIds.responseUpvotes);
+ const response = await this.client.post(url, data);
+ return this.normalizeUpvote(response.data);
+ }
+
+ async deleteResponseUpvote(upvoteId) {
+ if (!this.tableIds.responseUpvotes) {
+ throw new Error('Response upvotes table not configured');
+ }
+ const url = `${this.getTableUrl(this.tableIds.responseUpvotes)}/${upvoteId}`;
+ const response = await this.client.delete(url);
+ return response.data;
+ }
+
+ // Normalize response data from NocoDB format to application format
+ normalizeResponse(data) {
+ if (!data) return null;
+
+ return {
+ id: data.ID || data.Id || data.id,
+ campaign_id: data['Campaign ID'] || data.campaign_id,
+ campaign_slug: data['Campaign Slug'] || data.campaign_slug,
+ representative_name: data['Representative Name'] || data.representative_name,
+ representative_title: data['Representative Title'] || data.representative_title,
+ representative_level: data['Representative Level'] || data.representative_level,
+ response_type: data['Response Type'] || data.response_type,
+ response_text: data['Response Text'] || data.response_text,
+ user_comment: data['User Comment'] || data.user_comment,
+ screenshot_url: data['Screenshot URL'] || data.screenshot_url,
+ submitted_by_name: data['Submitted By Name'] || data.submitted_by_name,
+ submitted_by_email: data['Submitted By Email'] || data.submitted_by_email,
+ submitted_by_user_id: data['Submitted By User ID'] || data.submitted_by_user_id,
+ is_anonymous: data['Is Anonymous'] || data.is_anonymous || false,
+ status: data['Status'] || data.status,
+ is_verified: data['Is Verified'] || data.is_verified || false,
+ upvote_count: data['Upvote Count'] || data.upvote_count || 0,
+ submitted_ip: data['Submitted IP'] || data.submitted_ip,
+ created_at: data.CreatedAt || data.created_at,
+ updated_at: data.UpdatedAt || data.updated_at
+ };
+ }
+
+ // Normalize upvote data from NocoDB format to application format
+ normalizeUpvote(data) {
+ if (!data) return null;
+
+ return {
+ id: data.ID || data.Id || data.id,
+ response_id: data['Response ID'] || data.response_id,
+ user_id: data['User ID'] || data.user_id,
+ user_email: data['User Email'] || data.user_email,
+ upvoted_ip: data['Upvoted IP'] || data.upvoted_ip,
+ created_at: data.CreatedAt || data.created_at
+ };
+ }
}
-module.exports = new NocoDBService();
\ No newline at end of file
+module.exports = new NocoDBService();
diff --git a/influence/app/utils/validators.js b/influence/app/utils/validators.js
index 2d04749..8129e5c 100644
--- a/influence/app/utils/validators.js
+++ b/influence/app/utils/validators.js
@@ -78,6 +78,52 @@ function validateSlug(slug) {
return slugPattern.test(slug) && slug.length >= 3 && slug.length <= 100;
}
+// Validate response submission
+function validateResponse(data) {
+ const { representative_name, representative_level, response_type, response_text } = data;
+
+ // Check required fields
+ if (!representative_name || representative_name.trim() === '') {
+ return { valid: false, error: 'Representative name is required' };
+ }
+
+ if (!representative_level || representative_level.trim() === '') {
+ return { valid: false, error: 'Representative level is required' };
+ }
+
+ // Validate representative level
+ const validLevels = ['Federal', 'Provincial', 'Municipal', 'School Board'];
+ if (!validLevels.includes(representative_level)) {
+ return { valid: false, error: 'Invalid representative level' };
+ }
+
+ if (!response_type || response_type.trim() === '') {
+ return { valid: false, error: 'Response type is required' };
+ }
+
+ // Validate response type
+ const validTypes = ['Email', 'Letter', 'Phone Call', 'Meeting', 'Social Media', 'Other'];
+ if (!validTypes.includes(response_type)) {
+ return { valid: false, error: 'Invalid response type' };
+ }
+
+ if (!response_text || response_text.trim() === '') {
+ return { valid: false, error: 'Response text is required' };
+ }
+
+ // Check for suspicious content
+ if (containsSuspiciousContent(response_text)) {
+ return { valid: false, error: 'Response contains invalid content' };
+ }
+
+ // Validate email if provided
+ if (data.submitted_by_email && !validateEmail(data.submitted_by_email)) {
+ return { valid: false, error: 'Invalid email address' };
+ }
+
+ return { valid: true };
+}
+
module.exports = {
validatePostalCode,
validateAlbertaPostalCode,
@@ -87,5 +133,6 @@ module.exports = {
validateRequiredFields,
containsSuspiciousContent,
generateSlug,
- validateSlug
+ validateSlug,
+ validateResponse
};
\ No newline at end of file
diff --git a/influence/scripts/build-nocodb.sh b/influence/scripts/build-nocodb.sh
index d437b92..2a32326 100755
--- a/influence/scripts/build-nocodb.sh
+++ b/influence/scripts/build-nocodb.sh
@@ -1330,6 +1330,201 @@ create_call_logs_table() {
create_table "$base_id" "influence_call_logs" "$table_data" "Phone call tracking logs"
}
+# Function to create the representative responses table
+create_representative_responses_table() {
+ local base_id=$1
+
+ local table_data='{
+ "table_name": "influence_representative_responses",
+ "title": "Representative Responses",
+ "columns": [
+ {
+ "column_name": "id",
+ "title": "ID",
+ "uidt": "ID",
+ "pk": true,
+ "ai": true,
+ "rqd": true
+ },
+ {
+ "column_name": "campaign_id",
+ "title": "Campaign ID",
+ "uidt": "SingleLineText",
+ "rqd": true
+ },
+ {
+ "column_name": "campaign_slug",
+ "title": "Campaign Slug",
+ "uidt": "SingleLineText",
+ "rqd": true
+ },
+ {
+ "column_name": "representative_name",
+ "title": "Representative Name",
+ "uidt": "SingleLineText",
+ "rqd": true
+ },
+ {
+ "column_name": "representative_title",
+ "title": "Representative Title",
+ "uidt": "SingleLineText",
+ "rqd": false
+ },
+ {
+ "column_name": "representative_level",
+ "title": "Representative Level",
+ "uidt": "SingleSelect",
+ "rqd": true,
+ "colOptions": {
+ "options": [
+ {"title": "Federal", "color": "#e74c3c"},
+ {"title": "Provincial", "color": "#3498db"},
+ {"title": "Municipal", "color": "#2ecc71"},
+ {"title": "School Board", "color": "#f39c12"}
+ ]
+ }
+ },
+ {
+ "column_name": "response_type",
+ "title": "Response Type",
+ "uidt": "SingleSelect",
+ "rqd": true,
+ "colOptions": {
+ "options": [
+ {"title": "Email", "color": "#3498db"},
+ {"title": "Letter", "color": "#9b59b6"},
+ {"title": "Phone Call", "color": "#1abc9c"},
+ {"title": "Meeting", "color": "#e67e22"},
+ {"title": "Social Media", "color": "#34495e"},
+ {"title": "Other", "color": "#95a5a6"}
+ ]
+ }
+ },
+ {
+ "column_name": "response_text",
+ "title": "Response Text",
+ "uidt": "LongText",
+ "rqd": true
+ },
+ {
+ "column_name": "user_comment",
+ "title": "User Comment",
+ "uidt": "LongText",
+ "rqd": false
+ },
+ {
+ "column_name": "screenshot_url",
+ "title": "Screenshot URL",
+ "uidt": "SingleLineText",
+ "rqd": false
+ },
+ {
+ "column_name": "submitted_by_name",
+ "title": "Submitted By Name",
+ "uidt": "SingleLineText",
+ "rqd": false
+ },
+ {
+ "column_name": "submitted_by_email",
+ "title": "Submitted By Email",
+ "uidt": "Email",
+ "rqd": false
+ },
+ {
+ "column_name": "submitted_by_user_id",
+ "title": "Submitted By User ID",
+ "uidt": "SingleLineText",
+ "rqd": false
+ },
+ {
+ "column_name": "is_anonymous",
+ "title": "Is Anonymous",
+ "uidt": "Checkbox",
+ "cdf": "false"
+ },
+ {
+ "column_name": "status",
+ "title": "Status",
+ "uidt": "SingleSelect",
+ "cdf": "pending",
+ "colOptions": {
+ "options": [
+ {"title": "pending", "color": "#f39c12"},
+ {"title": "approved", "color": "#2ecc71"},
+ {"title": "rejected", "color": "#e74c3c"}
+ ]
+ }
+ },
+ {
+ "column_name": "is_verified",
+ "title": "Is Verified",
+ "uidt": "Checkbox",
+ "cdf": "false"
+ },
+ {
+ "column_name": "upvote_count",
+ "title": "Upvote Count",
+ "uidt": "Number",
+ "cdf": "0"
+ },
+ {
+ "column_name": "submitted_ip",
+ "title": "Submitted IP",
+ "uidt": "SingleLineText",
+ "rqd": false
+ }
+ ]
+ }'
+
+ create_table "$base_id" "influence_representative_responses" "$table_data" "Community responses from representatives"
+}
+
+# Function to create the response upvotes table
+create_response_upvotes_table() {
+ local base_id=$1
+
+ local table_data='{
+ "table_name": "influence_response_upvotes",
+ "title": "Response Upvotes",
+ "columns": [
+ {
+ "column_name": "id",
+ "title": "ID",
+ "uidt": "ID",
+ "pk": true,
+ "ai": true,
+ "rqd": true
+ },
+ {
+ "column_name": "response_id",
+ "title": "Response ID",
+ "uidt": "SingleLineText",
+ "rqd": true
+ },
+ {
+ "column_name": "user_id",
+ "title": "User ID",
+ "uidt": "SingleLineText",
+ "rqd": false
+ },
+ {
+ "column_name": "user_email",
+ "title": "User Email",
+ "uidt": "Email",
+ "rqd": false
+ },
+ {
+ "column_name": "upvoted_ip",
+ "title": "Upvoted IP",
+ "uidt": "SingleLineText",
+ "rqd": false
+ }
+ ]
+ }'
+
+ create_table "$base_id" "influence_response_upvotes" "$table_data" "Track upvotes on responses"
+}
+
# Function to create the users table
create_users_table() {
local base_id="$1"
@@ -1451,6 +1646,8 @@ update_env_with_table_ids() {
local campaign_emails_table_id=$6
local users_table_id=$7
local call_logs_table_id=$8
+ local representative_responses_table_id=$9
+ local response_upvotes_table_id=${10}
print_status "Updating .env file with NocoDB project and table IDs..."
@@ -1486,6 +1683,8 @@ update_env_with_table_ids() {
update_env_var "NOCODB_TABLE_CAMPAIGN_EMAILS" "$campaign_emails_table_id"
update_env_var "NOCODB_TABLE_USERS" "$users_table_id"
update_env_var "NOCODB_TABLE_CALLS" "$call_logs_table_id"
+ update_env_var "NOCODB_TABLE_REPRESENTATIVE_RESPONSES" "$representative_responses_table_id"
+ update_env_var "NOCODB_TABLE_RESPONSE_UPVOTES" "$response_upvotes_table_id"
print_success "Successfully updated .env file with all table IDs"
@@ -1500,6 +1699,8 @@ update_env_with_table_ids() {
print_status "NOCODB_TABLE_CAMPAIGN_EMAILS=$campaign_emails_table_id"
print_status "NOCODB_TABLE_USERS=$users_table_id"
print_status "NOCODB_TABLE_CALLS=$call_logs_table_id"
+ print_status "NOCODB_TABLE_REPRESENTATIVE_RESPONSES=$representative_responses_table_id"
+ print_status "NOCODB_TABLE_RESPONSE_UPVOTES=$response_upvotes_table_id"
}
@@ -1607,8 +1808,22 @@ main() {
exit 1
fi
+ # Create representative responses table
+ REPRESENTATIVE_RESPONSES_TABLE_ID=$(create_representative_responses_table "$BASE_ID")
+ if [[ $? -ne 0 ]]; then
+ print_error "Failed to create representative responses table"
+ exit 1
+ fi
+
+ # Create response upvotes table
+ RESPONSE_UPVOTES_TABLE_ID=$(create_response_upvotes_table "$BASE_ID")
+ if [[ $? -ne 0 ]]; then
+ print_error "Failed to create response upvotes table"
+ exit 1
+ fi
+
# Validate all table IDs were created successfully
- if ! validate_table_ids "$REPRESENTATIVES_TABLE_ID" "$EMAIL_LOGS_TABLE_ID" "$POSTAL_CODES_TABLE_ID" "$CAMPAIGNS_TABLE_ID" "$CAMPAIGN_EMAILS_TABLE_ID" "$USERS_TABLE_ID" "$CALL_LOGS_TABLE_ID"; then
+ if ! validate_table_ids "$REPRESENTATIVES_TABLE_ID" "$EMAIL_LOGS_TABLE_ID" "$POSTAL_CODES_TABLE_ID" "$CAMPAIGNS_TABLE_ID" "$CAMPAIGN_EMAILS_TABLE_ID" "$USERS_TABLE_ID" "$CALL_LOGS_TABLE_ID" "$REPRESENTATIVE_RESPONSES_TABLE_ID" "$RESPONSE_UPVOTES_TABLE_ID"; then
print_error "One or more table IDs are invalid"
exit 1
fi
@@ -1630,6 +1845,8 @@ main() {
table_mapping["influence_campaign_emails"]="$CAMPAIGN_EMAILS_TABLE_ID"
table_mapping["influence_users"]="$USERS_TABLE_ID"
table_mapping["influence_call_logs"]="$CALL_LOGS_TABLE_ID"
+ table_mapping["influence_representative_responses"]="$REPRESENTATIVE_RESPONSES_TABLE_ID"
+ table_mapping["influence_response_upvotes"]="$RESPONSE_UPVOTES_TABLE_ID"
# Get source table information
local source_tables_response
@@ -1682,6 +1899,8 @@ main() {
print_status " - influence_campaign_emails (ID: $CAMPAIGN_EMAILS_TABLE_ID)"
print_status " - influence_users (ID: $USERS_TABLE_ID)"
print_status " - influence_call_logs (ID: $CALL_LOGS_TABLE_ID)"
+ print_status " - influence_representative_responses (ID: $REPRESENTATIVE_RESPONSES_TABLE_ID)"
+ print_status " - influence_response_upvotes (ID: $RESPONSE_UPVOTES_TABLE_ID)"
# Automatically update .env file with new project ID
print_status ""
@@ -1704,7 +1923,7 @@ main() {
fi
# Update .env file with table IDs
- update_env_with_table_ids "$BASE_ID" "$REPRESENTATIVES_TABLE_ID" "$EMAIL_LOGS_TABLE_ID" "$POSTAL_CODES_TABLE_ID" "$CAMPAIGNS_TABLE_ID" "$CAMPAIGN_EMAILS_TABLE_ID" "$USERS_TABLE_ID" "$CALL_LOGS_TABLE_ID"
+ update_env_with_table_ids "$BASE_ID" "$REPRESENTATIVES_TABLE_ID" "$EMAIL_LOGS_TABLE_ID" "$POSTAL_CODES_TABLE_ID" "$CAMPAIGNS_TABLE_ID" "$CAMPAIGN_EMAILS_TABLE_ID" "$USERS_TABLE_ID" "$CALL_LOGS_TABLE_ID" "$REPRESENTATIVE_RESPONSES_TABLE_ID" "$RESPONSE_UPVOTES_TABLE_ID"
print_status ""
print_status "============================================================"