From b71a6e4ff3f15dee1b79dfade927074cfd05e345 Mon Sep 17 00:00:00 2001 From: admin Date: Tue, 14 Oct 2025 11:28:19 -0600 Subject: [PATCH] Updates to the html for url construction throughout and a bunch of upgrades to how the response wall works --- README.md | 12 +- influence/ADMIN_INLINE_HANDLER_FIX.md | 191 -- influence/RESPONSE_WALL_FIXES.md | 181 - influence/RESPONSE_WALL_IMPLEMENTATION.md | 3624 --------------------- influence/RESPONSE_WALL_USAGE.md | 204 -- influence/app/controllers/campaigns.js | 4 + influence/app/public/admin.html | 8 + influence/app/public/campaign.html | 270 +- influence/app/public/dashboard.html | 8 + influence/app/public/index.html | 17 +- influence/app/public/js/admin.js | 3 + influence/app/public/js/campaign.js | 165 +- influence/app/public/js/dashboard.js | 5 +- influence/app/public/login.html | 13 +- influence/app/public/terms.html | 14 +- influence/app/server.js | 7 + influence/app/services/nocodb.js | 2 + influence/scripts/build-nocodb.sh | 6 + 18 files changed, 482 insertions(+), 4252 deletions(-) delete mode 100644 influence/ADMIN_INLINE_HANDLER_FIX.md delete mode 100644 influence/RESPONSE_WALL_FIXES.md delete mode 100644 influence/RESPONSE_WALL_IMPLEMENTATION.md delete mode 100644 influence/RESPONSE_WALL_USAGE.md diff --git a/README.md b/README.md index 4442c0f..d954119 100644 --- a/README.md +++ b/README.md @@ -96,4 +96,14 @@ Complete documentation is available in the MkDocs site, including: - Map application setup and usage - Troubleshooting guides -Visit http://localhost:4000 after starting services to access the full documentation. \ No newline at end of file +Visit http://localhost:4000 after starting services to access the full documentation. + +## Licensing + +This project is licensed under the Apache License 2.0 - https://opensource.org/license/apache-2-0 + +## AI Disclaimer + +This project used AI tools to assist in its creation and large amounts of the boilerplate code was reviewed using AI. AI tools (although not activated or connected) are pre-installed in the Coder docker image. See `docker.code-server` for more details. + +While these tools can help generate code and documentation, they may also introduce errors or inaccuracies. Users should review and test all content to ensure it meets their requirements and standards. \ No newline at end of file diff --git a/influence/ADMIN_INLINE_HANDLER_FIX.md b/influence/ADMIN_INLINE_HANDLER_FIX.md deleted file mode 100644 index 880dc7d..0000000 --- a/influence/ADMIN_INLINE_HANDLER_FIX.md +++ /dev/null @@ -1,191 +0,0 @@ -# Response Wall Admin Panel - Inline Handler Fix Summary - -## Issue -Content Security Policy (CSP) violation when clicking buttons in the Response Moderation tab of the admin panel. - -## Error Messages -``` -Refused to execute inline event handler because it violates the following Content Security Policy directive: "script-src-attr 'none'" -TypeError: window.apiClient.patch is not a function -``` - -## Root Causes - -### 1. Inline Event Handlers (CSP Violation) -The admin panel's Response Moderation tab was using inline `onclick` handlers: -```javascript - -``` - -This violates: -- Browser Content Security Policy (CSP) -- Project development guidelines in `instruct.md`: **"No inline event handlers. Always use addEventListener in JS files."** - -### 2. Missing API Client Methods -The `APIClient` class only had `get()` and `post()` methods, but admin operations needed `patch()`, `put()`, and `delete()`. - -## Solutions Implemented - -### Fix 1: Removed All Inline Handlers in admin.js - -**Before:** -```javascript - -``` - -**After:** -```javascript - -``` - -### Fix 2: Added Event Delegation - -Created new `setupResponseActionListeners()` method in `AdminPanel` class: - -```javascript -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 with event delegation - const listener = (e) => { - const target = e.target; - const action = target.dataset.action; - const responseId = target.dataset.responseId; - - if (!action || !responseId) 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); -} -``` - -This method is called at the end of `renderAdminResponses()` to set up listeners after the HTML is rendered. - -### Fix 3: Added Missing HTTP Methods to api-client.js - -```javascript -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' - }); -} -``` - -## Buttons Fixed - -All response moderation buttons now use proper event delegation: - -1. **Approve** - `data-action="approve-response"` -2. **Reject** - `data-action="reject-response"` -3. **Mark as Verified** - `data-action="verify-response" data-verified="true"` -4. **Remove Verification** - `data-action="verify-response" data-verified="false"` -5. **Unpublish** - `data-action="reject-response"` (reuses reject action) -6. **Delete** - `data-action="delete-response"` - -## Files Modified - -1. **app/public/js/admin.js** - - Modified `renderAdminResponses()` - Replaced inline onclick with data attributes - - Added `setupResponseActionListeners()` - Event delegation implementation - - Total changes: ~85 lines modified/added - -2. **app/public/js/api-client.js** - - Added `put()` method - - Added `patch()` method - - Added `delete()` method - - Total changes: ~18 lines added - -## Benefits of This Approach - -### 1. Security -- ✅ Complies with Content Security Policy (CSP) -- ✅ Prevents script injection attacks -- ✅ Follows modern web security best practices - -### 2. Performance -- ✅ Single event listener instead of N listeners (one per button) -- ✅ Better memory usage -- ✅ Faster page rendering - -### 3. Maintainability -- ✅ Follows project coding standards (instruct.md) -- ✅ Centralized event handling logic -- ✅ Easy to add new actions without HTML changes - -### 4. Reliability -- ✅ Prevents duplicate listeners -- ✅ Clean listener removal/re-attachment -- ✅ Works with dynamically rendered content - -## Testing Checklist - -- [x] Load admin panel → Response Moderation tab -- [x] Click "Approve" button → Should approve response without CSP error -- [x] Click "Reject" button → Should reject response -- [x] Click "Mark as Verified" → Should add verification badge -- [x] Click "Remove Verification" → Should remove badge -- [x] Click "Delete" → Should delete response after confirmation -- [x] Filter responses by status → Should reload list -- [x] No console errors related to inline handlers -- [x] No CSP violations - -## Lessons Learned - -1. **Always Follow Project Guidelines**: The instruct.md file explicitly prohibits inline event handlers - following it from the start would have prevented this issue - -2. **Complete API Client**: When building a REST client, implement all HTTP methods (GET, POST, PUT, PATCH, DELETE) from the beginning - -3. **Event Delegation for Dynamic Content**: When rendering content dynamically with buttons/links, always use event delegation on a parent container - -4. **CSP is Your Friend**: Content Security Policy errors point to real security issues - fix them rather than disabling CSP - -## Related Documentation - -- See `instruct.md` - Development Rules section: "No inline event handlers" -- See `RESPONSE_WALL_FIXES.md` - Full list of all Response Wall bug fixes -- See `RESPONSE_WALL_USAGE.md` - How to use the Response Wall feature diff --git a/influence/RESPONSE_WALL_FIXES.md b/influence/RESPONSE_WALL_FIXES.md deleted file mode 100644 index 5bf05f5..0000000 --- a/influence/RESPONSE_WALL_FIXES.md +++ /dev/null @@ -1,181 +0,0 @@ -# Response Wall Bug Fixes - -## Issues Identified and Fixed - -### 1. **TypeError: responses.filter is not a function** -**Error Location**: `app/controllers/responses.js:292` in `getResponseStats()` - -**Root Cause**: The `getRepresentativeResponses()` method in `nocodb.js` was returning the raw NocoDB API response object `{list: [...], pageInfo: {...}}` instead of an array. The controller code expected an array and tried to call `.filter()` on an object. - -**Fix Applied**: Modified `getRepresentativeResponses()` to extract the `list` array from the response and normalize each item: - -```javascript -async getRepresentativeResponses(params = {}) { - if (!this.tableIds.representativeResponses) { - throw new Error('Representative responses table not configured'); - } - const result = await this.getAll(this.tableIds.representativeResponses, params); - // NocoDB returns {list: [...]} or {pageInfo: {...}, list: [...]} - const list = result.list || []; - return list.map(item => this.normalizeResponse(item)); -} -``` - -### 2. **TypeError: responses.map is not a function** -**Error Location**: `app/controllers/responses.js:51` in `getCampaignResponses()` - -**Root Cause**: Same as issue #1 - the method was returning an object instead of an array. - -**Fix Applied**: Same fix as above ensures an array is always returned. - -### 3. **Database Error: "A value is required for this field" (code 23502)** -**Error Location**: NocoDB database constraint violation when creating a response - -**Root Cause**: The `campaign_id` field was being set to `null` or `undefined`. Investigation revealed that: -- The campaign object from NocoDB uses `Id` (capital I) as the primary key field -- The controller was trying to access `campaign.id` (lowercase) which returned `undefined` -- NocoDB's representative_responses table has `campaign_id` marked as required (`"rqd": true`) - -**Fix Applied**: -1. Updated `submitResponse()` controller to check multiple possible field names: -```javascript -campaign_id: campaign.Id || campaign.id || campaign['Campaign ID'], -``` - -2. Added validation in `createRepresentativeResponse()` to fail fast if campaign_id is missing: -```javascript -if (!responseData.campaign_id) { - throw new Error('Campaign ID is required for creating a response'); -} -``` - -3. Added debug logging to track the campaign_id value: -```javascript -console.log('Submitting response with campaign_id:', newResponse.campaign_id, 'from campaign:', campaign); -``` - -### 4. **Array Handling in Response Upvotes** -**Potential Issue**: The `getResponseUpvotes()` method had the same array vs object issue - -**Fix Applied**: Updated the method to return a normalized array: -```javascript -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)); -} -``` - -## Files Modified - -1. **app/services/nocodb.js** - - Modified `getRepresentativeResponses()` - Extract and normalize list - - Modified `getResponseUpvotes()` - Extract and normalize list - - Modified `createRepresentativeResponse()` - Add campaign_id validation and logging - -2. **app/controllers/responses.js** - - Modified `submitResponse()` - Handle multiple campaign ID field name variations (ID, Id, id) - - Added debug logging for campaign_id - -3. **app/public/js/admin.js** - - Modified `renderAdminResponses()` - Removed all inline onclick handlers, replaced with data-action attributes - - Added `setupResponseActionListeners()` - Event delegation for response moderation buttons - - Follows instruct.md guidelines: "No inline event handlers. Always use addEventListener in JS files." - -4. **app/public/js/api-client.js** - - Added `put()` method for HTTP PUT requests - - Added `patch()` method for HTTP PATCH requests - - Added `delete()` method for HTTP DELETE requests - - These methods were missing and causing errors in admin panel operations - -## Testing - -After applying these fixes, test the following: - -1. **Load Response Wall** - Visit `http://localhost:3333/response-wall.html?campaign=test-page` - - Stats should load without errors - - Response list should load without errors - -2. **Submit Response** - Fill out and submit the response form - - Should successfully create a response in pending status - - Should return a success message - - Check logs for "Submitting response with campaign_id: [number]" - -3. **Upvote Response** - Click the upvote button on an approved response - - Should increment the upvote count - - Should prevent duplicate upvotes - -4. **Admin Moderation** - Visit `http://localhost:3333/admin.html` → Response Moderation tab - - Should see pending responses - - Should be able to approve/reject responses - -## Deployment - -The application container has been restarted with: -```bash -docker compose restart app -``` - -All fixes are now live and ready for testing. - -## Root Cause Analysis - -The main issue was a misunderstanding of NocoDB's API response structure: -- **Expected**: Array of records directly -- **Actual**: Object with `{list: [records], pageInfo: {...}}` - -This is a common pattern in REST APIs for pagination support. The fix ensures all service methods return properly normalized arrays for consistent usage throughout the application. - -The secondary issue was field naming inconsistency: -- **NocoDB Primary Key**: Uses `ID` (all caps) not `Id` or `id` -- **Application Code**: Expected `id` (lowercase) - -The fix handles all three variations to ensure compatibility: `campaign.ID || campaign.Id || campaign.id` - -### 5. **CSP Violation: Inline Event Handlers in Admin Panel** -**Error**: "Refused to execute inline event handler because it violates the following Content Security Policy directive: 'script-src-attr 'none''" - -**Root Cause**: The `renderAdminResponses()` method in admin.js was using inline `onclick` handlers like: -```javascript - - - - - - - -
- - - - - - - - - - - - - - - -``` - -#### File: `app/public/css/response-wall.css` (NEW FILE) - -Styles for the response wall: - -```css -/* 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: 1rem; - opacity: 0.9; -} - -.response-controls { - display: flex; - gap: 1rem; - margin-bottom: 2rem; - flex-wrap: wrap; - 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: #2c3e50; - 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; - font-style: italic; - color: #2c3e50; - line-height: 1.6; -} - -.user-comment { - padding: 0.75rem; - background: #fff9e6; - border-left: 4px solid #f39c12; - border-radius: 4px; - margin-bottom: 1rem; -} - -.user-comment-label { - font-weight: 600; - color: #f39c12; - 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; -} - -.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; - background: white; - border: 2px solid #3498db; - color: #3498db; - padding: 0.5rem 1rem; - 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: 4rem 2rem; - 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); -} - -.modal-content { - background-color: white; - margin: 5% auto; - padding: 2rem; - border-radius: 8px; - width: 90%; - 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-actions { - display: flex; - gap: 1rem; - margin-top: 1.5rem; -} - -.form-actions .btn { - flex: 1; -} - -/* Responsive Design */ -@media (max-width: 768px) { - .stats-banner { - flex-direction: column; - gap: 1.5rem; - } - - .response-controls { - flex-direction: column; - align-items: stretch; - } - - #submit-response-btn { - margin-left: 0; - width: 100%; - } - - .response-header { - flex-direction: column; - gap: 1rem; - } - - .response-footer { - flex-direction: column; - align-items: flex-start; - gap: 1rem; - } - - .modal-content { - margin: 10% auto; - width: 95%; - padding: 1.5rem; - } -} -``` - -#### File: `app/public/js/response-wall.js` (NEW FILE) - -JavaScript for response wall functionality: - -```javascript -// Response Wall JavaScript - -let currentCampaignSlug = null; -let currentOffset = 0; -let currentSort = 'recent'; -let currentLevel = ''; -const LIMIT = 20; - -// Initialize -document.addEventListener('DOMContentLoaded', () => { - // Get campaign slug from URL if present - const urlParams = new URLSearchParams(window.location.search); - currentCampaignSlug = urlParams.get('campaign'); - - if (!currentCampaignSlug) { - showError('No campaign specified'); - return; - } - - loadResponseStats(); - loadResponses(); - - // Event listeners - document.getElementById('sort-select').addEventListener('change', (e) => { - currentSort = e.target.value; - currentOffset = 0; - loadResponses(true); - }); - - document.getElementById('level-filter').addEventListener('change', (e) => { - currentLevel = e.target.value; - currentOffset = 0; - loadResponses(true); - }); - - document.getElementById('submit-response-btn').addEventListener('click', openSubmitModal); - document.getElementById('load-more-btn').addEventListener('click', loadMoreResponses); - document.getElementById('submit-response-form').addEventListener('submit', handleSubmitResponse); -}); - -// 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('total-responses').textContent = data.stats.total_responses; - document.getElementById('verified-responses').textContent = data.stats.verified_responses; - document.getElementById('unique-reps').textContent = data.stats.unique_representatives; - } - } 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 { - let url = `/api/campaigns/${currentCampaignSlug}/responses?sort=${currentSort}&limit=${LIMIT}&offset=${currentOffset}`; - - if (currentLevel) { - url += `&level=${currentLevel}`; - } - - const response = await fetch(url); - const data = await response.json(); - - if (data.success) { - if (data.responses.length === 0 && currentOffset === 0) { - showEmptyState(); - } else { - renderResponses(data.responses); - - // Show/hide load more button - const loadMoreContainer = document.getElementById('load-more-container'); - if (data.pagination.hasMore) { - loadMoreContainer.style.display = 'block'; - } else { - loadMoreContainer.style.display = 'none'; - } - } - } else { - showError('Failed to load responses'); - } - } catch (error) { - console.error('Error loading responses:', error); - showError('Failed to load responses'); - } finally { - showLoading(false); - } -} - -// Render responses -function renderResponses(responses) { - const container = document.getElementById('responses-container'); - document.getElementById('empty-state').style.display = 'none'; - - 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; - - // Format date - const date = new Date(response.created_at); - const formattedDate = date.toLocaleDateString('en-US', { - year: 'numeric', - month: 'short', - day: 'numeric' - }); - - // Build badges - let badges = `${response.representative_level}`; - badges += `${response.response_type}`; - if (response.verified) { - badges = `✓ Verified` + badges; - } - - // Build screenshot if present - let screenshotHTML = ''; - if (response.response_screenshot) { - screenshotHTML = ` -
- Response screenshot -
- `; - } - - // Build user comment if present - let commentHTML = ''; - if (response.user_comment) { - commentHTML = ` -
- Community Member's Thoughts: - ${escapeHtml(response.user_comment)} -
- `; - } - - // Build district info - let districtInfo = ''; - if (response.representative_district) { - districtInfo = `📍 ${escapeHtml(response.representative_district)}`; - } - - card.innerHTML = ` -
-
-

${escapeHtml(response.representative_name)}

-
- ${districtInfo} -
-
-
- ${badges} -
-
- -
-
- "${escapeHtml(response.response_text)}" -
- ${commentHTML} - ${screenshotHTML} -
- - - `; - - return card; -} - -// Toggle upvote -async function toggleUpvote(responseId, button) { - const isUpvoted = button.classList.contains('upvoted'); - const countElement = button.querySelector('.upvote-count'); - const currentCount = parseInt(countElement.textContent); - - try { - const method = isUpvoted ? 'DELETE' : 'POST'; - const response = await fetch(`/api/responses/${responseId}/upvote`, { - method: method, - headers: { - 'Content-Type': 'application/json' - } - }); - - const data = await response.json(); - - if (data.success) { - button.classList.toggle('upvoted'); - countElement.textContent = data.upvotes; - } 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() { - document.getElementById('submit-modal').style.display = 'block'; -} - -// 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(); - formData.append('representative_name', document.getElementById('rep-name').value.trim()); - formData.append('representative_level', document.getElementById('rep-level').value); - formData.append('representative_district', document.getElementById('rep-district').value.trim()); - formData.append('response_type', document.getElementById('response-type').value); - formData.append('response_text', document.getElementById('response-text').value.trim()); - formData.append('user_comment', document.getElementById('user-comment').value.trim()); - formData.append('is_anonymous', document.getElementById('is-anonymous').checked); - - const screenshotFile = document.getElementById('screenshot').files[0]; - if (screenshotFile) { - formData.append('screenshot', screenshotFile); - } - - try { - const submitButton = e.target.querySelector('button[type="submit"]'); - submitButton.disabled = true; - submitButton.textContent = 'Submitting...'; - - const response = await fetch(`/api/campaigns/${currentCampaignSlug}/responses`, { - method: 'POST', - body: formData - }); - - const data = await response.json(); - - if (data.success) { - showSuccess('Response submitted successfully! It will appear after admin approval.'); - closeSubmitModal(); - } else { - showError(data.error || 'Failed to submit response'); - } - } catch (error) { - console.error('Error submitting response:', error); - showError('Failed to submit response'); - } finally { - const submitButton = e.target.querySelector('button[type="submit"]'); - submitButton.disabled = false; - submitButton.textContent = '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 = ''; -} - -function showError(message) { - // Could integrate with existing error display system - alert(message); -} - -function showSuccess(message) { - // Could integrate with existing success display system - alert(message); -} - -function escapeHtml(text) { - 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(); - } -}; -``` - ---- - -### Phase 4: Admin Panel Integration - -#### File: `app/public/admin.html` (UPDATE) - -Add response moderation section to existing admin panel: - -```html - - -``` - -#### File: `app/public/js/admin.js` (UPDATE) - -Add response moderation functions: - -```javascript -// Add these functions to existing admin.js - -async function loadAdminResponses() { - const status = document.getElementById('admin-response-status').value; - - try { - const response = await fetch(`/api/admin/responses?status=${status}`, { - headers: { - 'Authorization': `Bearer ${localStorage.getItem('token')}` - } - }); - - const data = await response.json(); - - if (data.success) { - renderAdminResponses(data.responses); - } - } catch (error) { - console.error('Error loading responses:', error); - } -} - -function renderAdminResponses(responses) { - const container = document.getElementById('admin-responses-container'); - - if (responses.length === 0) { - container.innerHTML = '

No responses found.

'; - return; - } - - container.innerHTML = responses.map(response => ` -
-

${response.representative_name} (${response.representative_level})

-

Response: ${response.response_text.substring(0, 200)}...

- ${response.user_comment ? `

User Comment: ${response.user_comment}

` : ''} - ${response.response_screenshot ? `` : ''} - -
- ${response.status === 'pending' ? ` - - - ` : ''} - - -
-
- `).join(''); -} - -async function approveResponse(id) { - await updateResponseStatus(id, 'approved'); -} - -async function rejectResponse(id) { - await updateResponseStatus(id, 'rejected'); -} - -async function updateResponseStatus(id, status) { - try { - const response = await fetch(`/api/admin/responses/${id}/status`, { - method: 'PATCH', - headers: { - 'Authorization': `Bearer ${localStorage.getItem('token')}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ status }) - }); - - const data = await response.json(); - - if (data.success) { - showSuccess(`Response ${status}`); - loadAdminResponses(); - } - } catch (error) { - console.error('Error updating response:', error); - showError('Failed to update response'); - } -} - -async function toggleVerified(id, currentlyVerified) { - try { - const response = await fetch(`/api/admin/responses/${id}`, { - method: 'PATCH', - headers: { - 'Authorization': `Bearer ${localStorage.getItem('token')}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ verified: !currentlyVerified }) - }); - - const data = await response.json(); - - if (data.success) { - showSuccess('Response updated'); - loadAdminResponses(); - } - } catch (error) { - console.error('Error toggling verified:', error); - } -} - -async function deleteResponse(id) { - if (!confirm('Are you sure you want to delete this response?')) return; - - try { - const response = await fetch(`/api/admin/responses/${id}`, { - method: 'DELETE', - headers: { - 'Authorization': `Bearer ${localStorage.getItem('token')}` - } - }); - - const data = await response.json(); - - if (data.success) { - showSuccess('Response deleted'); - loadAdminResponses(); - } - } catch (error) { - console.error('Error deleting response:', error); - } -} -``` - -#### File: `app/controllers/responses.js` (UPDATE) - -Add admin endpoints: - -```javascript -// Add these admin functions to responses controller - -async function getAdminResponses(req, res) { - try { - const { status = 'pending' } = req.query; - - const responses = await nocodbService.getRecords('representative_responses', { - where: `(status,eq,${status})`, - sort: '-created_at', - limit: 100 - }); - - res.json({ success: true, responses }); - } catch (error) { - console.error('Error fetching admin responses:', error); - res.status(500).json({ error: 'Failed to fetch responses' }); - } -} - -async function updateResponseStatus(req, res) { - try { - const { id } = req.params; - const { status } = req.body; - - if (!['pending', 'approved', 'rejected'].includes(status)) { - return res.status(400).json({ error: 'Invalid status' }); - } - - await nocodbService.updateRecord('representative_responses', id, { - status, - updated_at: new Date().toISOString() - }); - - res.json({ success: true, message: 'Response status updated' }); - } catch (error) { - console.error('Error updating response status:', error); - res.status(500).json({ error: 'Failed to update status' }); - } -} - -async function updateResponse(req, res) { - try { - const { id } = req.params; - const updates = req.body; - - updates.updated_at = new Date().toISOString(); - - await nocodbService.updateRecord('representative_responses', id, updates); - - res.json({ success: true, message: 'Response updated' }); - } catch (error) { - console.error('Error updating response:', error); - res.status(500).json({ error: 'Failed to update response' }); - } -} - -async function deleteResponse(req, res) { - try { - const { id } = req.params; - - await nocodbService.deleteRecord('representative_responses', id); - - res.json({ success: true, message: 'Response deleted' }); - } catch (error) { - console.error('Error deleting response:', error); - res.status(500).json({ error: 'Failed to delete response' }); - } -} - -// Export these new functions -module.exports = { - getCampaignResponses, - submitResponse, - upvoteResponse, - removeUpvote, - getResponseStats, - // Admin functions - getAdminResponses, - updateResponseStatus, - updateResponse, - deleteResponse -}; -``` - -#### File: `app/routes/api.js` (UPDATE) - -Add admin routes: - -```javascript -// Add these admin routes (after other admin routes) -router.get('/admin/responses', requireAdmin, responsesController.getAdminResponses); -router.patch('/admin/responses/:id/status', requireAdmin, responsesController.updateResponseStatus); -router.patch('/admin/responses/:id', requireAdmin, responsesController.updateResponse); -router.delete('/admin/responses/:id', requireAdmin, responsesController.deleteResponse); -``` - ---- - -### Phase 5: Integration with Campaign Page - -#### File: `app/public/campaign.html` (UPDATE) - -Add response wall section to existing campaign page: - -```html - -
-

Community Responses

-

See what responses community members are getting from their representatives

- -
- -
- - -
-``` - -#### File: `app/public/js/campaign.js` (UPDATE) - -Add response preview loading: - -```javascript -// Add this function to existing campaign.js - -async function loadResponsePreview() { - try { - const response = await fetch(`/api/campaigns/${campaignSlug}/responses?limit=3&sort=popular`); - const data = await response.json(); - - if (data.success && data.responses.length > 0) { - renderResponsePreview(data.responses); - document.getElementById('response-wall-section').style.display = 'block'; - } - } catch (error) { - console.error('Error loading response preview:', error); - } -} - -function renderResponsePreview(responses) { - const container = document.getElementById('campaign-response-preview'); - - container.innerHTML = responses.map(response => ` -
- ${response.representative_name} (${response.representative_level}) -

${response.response_text.substring(0, 150)}...

- 👍 ${response.upvotes || 0} -
- `).join(''); -} - -function viewAllResponses() { - window.location.href = `/response-wall.html?campaign=${campaignSlug}`; -} - -// Call this in your campaign init function -loadResponsePreview(); -``` - ---- - -## Testing Checklist - -### Backend Testing -- [ ] Database tables created successfully -- [ ] POST response submission works (with/without auth) -- [ ] GET responses returns correct data -- [ ] Upvote/downvote works correctly -- [ ] Duplicate upvote prevention works -- [ ] File upload works for screenshots -- [ ] Admin endpoints require authentication -- [ ] Status updates work (pending/approved/rejected) - -### Frontend Testing -- [ ] Response wall page loads correctly -- [ ] Responses display with correct formatting -- [ ] Sorting works (recent, popular, verified) -- [ ] Filtering by level works -- [ ] Upvote button updates correctly -- [ ] Submit modal opens and closes -- [ ] Form validation works -- [ ] File upload works -- [ ] Anonymous posting works -- [ ] Admin moderation panel works - -### Integration Testing -- [ ] Campaign page shows response preview -- [ ] Stats update correctly -- [ ] Pagination/load more works -- [ ] Mobile responsive design -- [ ] Error handling shows appropriate messages - ---- - -## Deployment Steps - -1. **Update NocoDB Schema:** - ```bash - cd /path/to/influence - chmod +x scripts/build-nocodb.sh - ./scripts/build-nocodb.sh - ``` - -2. **Install Dependencies:** - ```bash - cd app - npm install multer - ``` - -3. **Create Upload Directory:** - ```bash - mkdir -p app/public/uploads/responses - ``` - -4. **Rebuild Docker Container:** - ```bash - docker compose down - docker compose build - docker compose up -d - ``` - -5. **Test Endpoints:** - - Visit campaign page - - Try submitting a response - - Check admin panel for moderation - ---- - ---- - -## Phase 6: Representative Accounts & Direct Response System - -### Overview -Allow elected representatives to create verified accounts and respond directly to campaign emails through the platform. These responses get a special "Verified by Representative" badge, creating authentic two-way dialogue. - -### Benefits -- **Authenticity**: Representatives control their own responses -- **Transparency**: Public record of representative positions -- **Engagement**: Encourages reps to participate directly -- **Accountability**: Official statements are permanently visible -- **Credibility**: Community trusts verified responses more - ---- - -### Database Schema Updates - -#### Update Table: `users` (Add Representative Fields) - -Add these columns to the existing `users` table: - -```bash -# Add to scripts/build-nocodb.sh - -# Add representative-specific columns to users table -curl -X POST "$NOCODB_API_URL/api/v1/db/meta/tables/{users_table_id}/columns" \ - -H "xc-token: $NOCODB_API_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "column_name": "user_type", - "title": "User Type", - "uidt": "SingleSelect", - "dtxp": "regular,representative,admin", - "cdf": "regular" - }' - -curl -X POST "$NOCODB_API_URL/api/v1/db/meta/tables/{users_table_id}/columns" \ - -H "xc-token: $NOCODB_API_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "column_name": "representative_name", - "title": "Representative Name", - "uidt": "SingleLineText" - }' - -curl -X POST "$NOCODB_API_URL/api/v1/db/meta/tables/{users_table_id}/columns" \ - -H "xc-token: $NOCODB_API_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "column_name": "representative_level", - "title": "Representative Level", - "uidt": "SingleSelect", - "dtxp": "Federal,Provincial,Municipal" - }' - -curl -X POST "$NOCODB_API_URL/api/v1/db/meta/tables/{users_table_id}/columns" \ - -H "xc-token: $NOCODB_API_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "column_name": "representative_district", - "title": "District/Riding", - "uidt": "SingleLineText" - }' - -curl -X POST "$NOCODB_API_URL/api/v1/db/meta/tables/{users_table_id}/columns" \ - -H "xc-token: $NOCODB_API_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "column_name": "representative_verified", - "title": "Representative Verified", - "uidt": "Checkbox", - "cdf": "false" - }' - -curl -X POST "$NOCODB_API_URL/api/v1/db/meta/tables/{users_table_id}/columns" \ - -H "xc-token: $NOCODB_API_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "column_name": "representative_bio", - "title": "Representative Bio", - "uidt": "LongText" - }' - -curl -X POST "$NOCODB_API_URL/api/v1/db/meta/tables/{users_table_id}/columns" \ - -H "xc-token: $NOCODB_API_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "column_name": "representative_photo", - "title": "Representative Photo", - "uidt": "Attachment" - }' - -curl -X POST "$NOCODB_API_URL/api/v1/db/meta/tables/{users_table_id}/columns" \ - -H "xc-token: $NOCODB_API_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "column_name": "verification_document", - "title": "Verification Document", - "uidt": "Attachment" - }' -``` - -#### Update Table: `representative_responses` (Add Representative Fields) - -Add these columns: - -```bash -curl -X POST "$NOCODB_API_URL/api/v1/db/meta/tables/{responses_table_id}/columns" \ - -H "xc-token: $NOCODB_API_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "column_name": "posted_by_representative", - "title": "Posted by Representative", - "uidt": "Checkbox", - "cdf": "false" - }' - -curl -X POST "$NOCODB_API_URL/api/v1/db/meta/tables/{responses_table_id}/columns" \ - -H "xc-token: $NOCODB_API_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "column_name": "representative_user_id", - "title": "Representative User ID", - "uidt": "Number" - }' -``` - -#### New Table: `representative_inbox` - -Track emails sent to representatives through campaigns: - -```bash -curl -X POST "$NOCODB_API_URL/api/v1/db/meta/tables" \ - -H "xc-token: $NOCODB_API_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "table_name": "representative_inbox", - "title": "Representative Inbox", - "columns": [ - { - "column_name": "id", - "title": "ID", - "uidt": "ID", - "pk": true, - "ai": true - }, - { - "column_name": "campaign_id", - "title": "Campaign ID", - "uidt": "Number", - "rqd": true - }, - { - "column_name": "email_log_id", - "title": "Email Log ID", - "uidt": "Number" - }, - { - "column_name": "representative_name", - "title": "Representative Name", - "uidt": "SingleLineText", - "rqd": true - }, - { - "column_name": "representative_email", - "title": "Representative Email", - "uidt": "SingleLineText", - "rqd": true - }, - { - "column_name": "representative_level", - "title": "Representative Level", - "uidt": "SingleSelect", - "dtxp": "Federal,Provincial,Municipal", - "rqd": true - }, - { - "column_name": "email_subject", - "title": "Email Subject", - "uidt": "SingleLineText" - }, - { - "column_name": "email_body", - "title": "Email Body", - "uidt": "LongText" - }, - { - "column_name": "sender_postal_code", - "title": "Sender Postal Code", - "uidt": "SingleLineText" - }, - { - "column_name": "has_response", - "title": "Has Response", - "uidt": "Checkbox", - "cdf": "false" - }, - { - "column_name": "response_id", - "title": "Response ID", - "uidt": "Number" - }, - { - "column_name": "created_at", - "title": "Created At", - "uidt": "DateTime", - "cdf": "CURRENT_TIMESTAMP" - } - ] - }' -``` - ---- - -### Backend Implementation - -#### File: `app/controllers/representativeController.js` (NEW FILE) - -```javascript -const nocodbService = require('../services/nocodb'); -const emailService = require('../services/email'); -const { validateResponse } = require('../utils/validators'); - -/** - * Get representative's inbox - emails sent to them through campaigns - * GET /api/representative/inbox - */ -async function getRepresentativeInbox(req, res) { - try { - if (!req.user || req.user.user_type !== 'representative') { - return res.status(403).json({ error: 'Representative access required' }); - } - - if (!req.user.representative_verified) { - return res.status(403).json({ error: 'Your representative account is pending verification' }); - } - - const { limit = 20, offset = 0, has_response } = req.query; - - // Build filters - let whereClause = `(representative_email,eq,${req.user.email})`; - - if (has_response !== undefined) { - whereClause += `~and(has_response,eq,${has_response})`; - } - - const emails = await nocodbService.getRecords('representative_inbox', { - where: whereClause, - sort: '-created_at', - limit, - offset - }); - - // Get campaign details for each email - const enrichedEmails = await Promise.all(emails.map(async (email) => { - const campaign = await nocodbService.getRecord('campaigns', email.campaign_id); - return { - ...email, - campaign_title: campaign?.title || 'Unknown Campaign', - campaign_slug: campaign?.slug - }; - })); - - res.json({ - success: true, - emails: enrichedEmails, - pagination: { - limit: parseInt(limit), - offset: parseInt(offset), - hasMore: emails.length === parseInt(limit) - } - }); - - } catch (error) { - console.error('Error fetching representative inbox:', error); - res.status(500).json({ error: 'Failed to fetch inbox' }); - } -} - -/** - * Get inbox statistics - * GET /api/representative/inbox/stats - */ -async function getInboxStats(req, res) { - try { - if (!req.user || req.user.user_type !== 'representative') { - return res.status(403).json({ error: 'Representative access required' }); - } - - const allEmails = await nocodbService.getRecords('representative_inbox', { - where: `(representative_email,eq,${req.user.email})`, - fields: 'has_response,campaign_id' - }); - - const stats = { - total_emails: allEmails.length, - unanswered: allEmails.filter(e => !e.has_response).length, - answered: allEmails.filter(e => e.has_response).length, - unique_campaigns: new Set(allEmails.map(e => e.campaign_id)).size - }; - - res.json({ success: true, stats }); - - } catch (error) { - console.error('Error fetching inbox stats:', error); - res.status(500).json({ error: 'Failed to fetch stats' }); - } -} - -/** - * Submit official response from representative - * POST /api/representative/respond/:inboxId - */ -async function submitOfficialResponse(req, res) { - try { - if (!req.user || req.user.user_type !== 'representative') { - return res.status(403).json({ error: 'Representative access required' }); - } - - if (!req.user.representative_verified) { - return res.status(403).json({ error: 'Your representative account is pending verification' }); - } - - const { inboxId } = req.params; - const { response_text, user_comment, response_type = 'Email' } = req.body; - - // Validate - if (!response_text || response_text.trim().length < 10) { - return res.status(400).json({ error: 'Response text is required (min 10 characters)' }); - } - - // Get inbox item - const inboxItem = await nocodbService.getRecord('representative_inbox', inboxId); - if (!inboxItem) { - return res.status(404).json({ error: 'Email not found' }); - } - - // Verify this email was sent to this representative - if (inboxItem.representative_email !== req.user.email) { - return res.status(403).json({ error: 'You can only respond to emails sent to you' }); - } - - // Handle file upload (screenshot) if present - let screenshot_url = null; - if (req.file) { - screenshot_url = `/uploads/responses/${req.file.filename}`; - } - - // Create verified response record - const responseData = { - campaign_id: inboxItem.campaign_id, - user_id: req.user.id, - representative_user_id: req.user.id, - representative_name: req.user.representative_name, - representative_level: req.user.representative_level, - representative_district: req.user.representative_district, - response_type, - response_text, - response_screenshot: screenshot_url, - user_comment, - is_anonymous: false, - posted_by_representative: true, - upvotes: 0, - verified: true, // Auto-verified since it's from the rep - status: 'approved', // Auto-approved - created_at: new Date().toISOString() - }; - - const newResponse = await nocodbService.createRecord('representative_responses', responseData); - - // Update inbox item - await nocodbService.updateRecord('representative_inbox', inboxId, { - has_response: true, - response_id: newResponse.Id - }); - - res.status(201).json({ - success: true, - message: 'Response published successfully', - response: newResponse - }); - - } catch (error) { - console.error('Error submitting official response:', error); - res.status(500).json({ error: 'Failed to submit response' }); - } -} - -/** - * Get representative profile (public) - * GET /api/representative/profile/:id - */ -async function getRepresentativeProfile(req, res) { - try { - const { id } = req.params; - - const user = await nocodbService.getRecord('users', id); - - if (!user || user.user_type !== 'representative') { - return res.status(404).json({ error: 'Representative not found' }); - } - - // Get response stats - const responses = await nocodbService.getRecords('representative_responses', { - where: `(representative_user_id,eq,${id})~and(status,eq,approved)`, - fields: 'Id,created_at,campaign_id,upvotes' - }); - - const profile = { - id: user.Id, - name: user.representative_name, - level: user.representative_level, - district: user.representative_district, - bio: user.representative_bio, - photo: user.representative_photo, - verified: user.representative_verified, - stats: { - total_responses: responses.length, - total_upvotes: responses.reduce((sum, r) => sum + (r.upvotes || 0), 0), - campaigns_responded: new Set(responses.map(r => r.campaign_id)).size, - member_since: user.created_at - } - }; - - res.json({ success: true, profile }); - - } catch (error) { - console.error('Error fetching representative profile:', error); - res.status(500).json({ error: 'Failed to fetch profile' }); - } -} - -/** - * Update representative profile - * PATCH /api/representative/profile - */ -async function updateRepresentativeProfile(req, res) { - try { - if (!req.user || req.user.user_type !== 'representative') { - return res.status(403).json({ error: 'Representative access required' }); - } - - const { representative_bio } = req.body; - const updates = {}; - - if (representative_bio !== undefined) { - updates.representative_bio = representative_bio; - } - - if (req.file) { - updates.representative_photo = `/uploads/representatives/${req.file.filename}`; - } - - if (Object.keys(updates).length > 0) { - await nocodbService.updateRecord('users', req.user.id, updates); - } - - res.json({ success: true, message: 'Profile updated successfully' }); - - } catch (error) { - console.error('Error updating profile:', error); - res.status(500).json({ error: 'Failed to update profile' }); - } -} - -/** - * Request representative verification - * POST /api/representative/request-verification - */ -async function requestVerification(req, res) { - try { - if (!req.user || req.user.user_type !== 'representative') { - return res.status(403).json({ error: 'Representative access required' }); - } - - const { representative_name, representative_level, representative_district } = req.body; - - if (!representative_name || !representative_level || !representative_district) { - return res.status(400).json({ error: 'All representative details are required' }); - } - - const updates = { - representative_name, - representative_level, - representative_district - }; - - if (req.file) { - updates.verification_document = `/uploads/verification/${req.file.filename}`; - } - - await nocodbService.updateRecord('users', req.user.id, updates); - - // Notify admins (could send email here) - console.log(`Verification requested by ${representative_name}`); - - res.json({ - success: true, - message: 'Verification request submitted. An administrator will review your request.' - }); - - } catch (error) { - console.error('Error requesting verification:', error); - res.status(500).json({ error: 'Failed to submit verification request' }); - } -} - -module.exports = { - getRepresentativeInbox, - getInboxStats, - submitOfficialResponse, - getRepresentativeProfile, - updateRepresentativeProfile, - requestVerification -}; -``` - -#### File: `app/middleware/auth.js` (UPDATE) - -Add representative check middleware: - -```javascript -// Add this new middleware function -async function requireRepresentative(req, res, next) { - try { - const token = req.headers.authorization?.split(' ')[1] || req.cookies?.token; - - if (!token) { - return res.status(401).json({ error: 'Authentication required' }); - } - - const decoded = jwt.verify(token, process.env.JWT_SECRET); - const user = await nocodbService.getUserById(decoded.userId); - - if (!user) { - return res.status(401).json({ error: 'Invalid token' }); - } - - if (user.user_type !== 'representative') { - return res.status(403).json({ error: 'Representative access required' }); - } - - req.user = user; - next(); - } catch (error) { - return res.status(401).json({ error: 'Invalid or expired token' }); - } -} - -// Export it -module.exports = { - requireAuth, - requireAdmin, - optionalAuth, - requireRepresentative // Add this -}; -``` - -#### File: `app/routes/api.js` (UPDATE) - -Add representative routes: - -```javascript -// Add at top with other requires -const representativeController = require('../controllers/representativeController'); - -// Add these routes (around line 60, after response routes) -// Representative Routes -router.get('/representative/inbox', requireRepresentative, representativeController.getRepresentativeInbox); -router.get('/representative/inbox/stats', requireRepresentative, representativeController.getInboxStats); -router.post('/representative/respond/:inboxId', - requireRepresentative, - upload.single('screenshot'), - representativeController.submitOfficialResponse -); -router.get('/representative/profile/:id', representativeController.getRepresentativeProfile); // Public -router.patch('/representative/profile', - requireRepresentative, - upload.single('photo'), - representativeController.updateRepresentativeProfile -); -router.post('/representative/request-verification', - requireRepresentative, - upload.single('verification_document'), - representativeController.requestVerification -); -``` - -#### File: `app/controllers/emails.js` (UPDATE) - -Log emails sent to representatives: - -```javascript -// Add this function call in your existing sendEmail function -// After successfully sending email to a representative - -async function logEmailToRepresentativeInbox(campaignId, representative, emailSubject, emailBody, senderPostalCode) { - try { - await nocodbService.createRecord('representative_inbox', { - campaign_id: campaignId, - representative_name: representative.name, - representative_email: representative.email, - representative_level: representative.elected_office, // Adjust based on your data structure - email_subject: emailSubject, - email_body: emailBody, - sender_postal_code: senderPostalCode, - has_response: false, - created_at: new Date().toISOString() - }); - } catch (error) { - console.error('Error logging email to representative inbox:', error); - // Don't fail the email send if logging fails - } -} - -// Call this function in your sendCampaignEmail function: -// logEmailToRepresentativeInbox(campaign.Id, representative, subject, body, postalCode); -``` - ---- - -### Frontend Implementation - -#### File: `app/public/representative-dashboard.html` (NEW FILE) - -Representative inbox and response interface: - -```html - - - - - - Representative Dashboard - BNKops Influence - - - - -
-
-

Representative Dashboard

-

Respond to your constituents directly

-
- - -
-
- - -
-
-
📧
-
- 0 - Total Emails Received -
-
-
-
-
- 0 - Awaiting Response -
-
-
-
-
- 0 - Responded -
-
-
-
📊
-
- 0 - Active Campaigns -
-
-
- - - - - -
-
- - -
- -
- - - - - -
- - - - - - -
- - - - - - - - -``` - -#### File: `app/public/css/representative.css` (NEW FILE) - -```css -/* Representative Dashboard Styles */ - -.header-actions { - display: flex; - gap: 1rem; - margin-top: 1rem; -} - -.rep-stats-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); - gap: 1.5rem; - margin-bottom: 2rem; -} - -.stat-card { - background: white; - border: 1px solid #e1e8ed; - border-radius: 8px; - padding: 1.5rem; - display: flex; - align-items: center; - gap: 1rem; - transition: transform 0.3s ease, box-shadow 0.3s ease; -} - -.stat-card:hover { - transform: translateY(-4px); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); -} - -.stat-icon { - font-size: 2.5rem; - opacity: 0.8; -} - -.stat-content { - display: flex; - flex-direction: column; -} - -.stat-number { - font-size: 2rem; - font-weight: bold; - color: #2c3e50; -} - -.stat-label { - font-size: 0.9rem; - color: #7f8c8d; - margin-top: 0.25rem; -} - -.notice { - padding: 1rem 1.5rem; - border-radius: 8px; - margin-bottom: 2rem; -} - -.notice-warning { - background: #fff9e6; - border: 1px solid #f39c12; - color: #856404; -} - -.inbox-controls { - display: flex; - gap: 1rem; - margin-bottom: 2rem; - align-items: center; -} - -.inbox-item { - background: white; - border: 1px solid #e1e8ed; - border-radius: 8px; - padding: 1.5rem; - margin-bottom: 1rem; - transition: box-shadow 0.3s ease; -} - -.inbox-item:hover { - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); -} - -.inbox-item.unread { - border-left: 4px solid #3498db; - background: #f8f9ff; -} - -.inbox-item.answered { - opacity: 0.7; -} - -.inbox-header { - display: flex; - justify-content: space-between; - align-items: flex-start; - margin-bottom: 1rem; -} - -.inbox-campaign-info h3 { - margin: 0 0 0.5rem 0; - color: #2c3e50; -} - -.inbox-campaign-info .campaign-meta { - color: #7f8c8d; - font-size: 0.9rem; -} - -.inbox-badges { - display: flex; - gap: 0.5rem; -} - -.inbox-body { - background: #f8f9fa; - padding: 1rem; - border-radius: 4px; - margin-bottom: 1rem; - max-height: 150px; - overflow: hidden; - position: relative; -} - -.inbox-body.expanded { - max-height: none; -} - -.inbox-body-fade { - position: absolute; - bottom: 0; - left: 0; - right: 0; - height: 50px; - background: linear-gradient(transparent, #f8f9fa); -} - -.inbox-footer { - display: flex; - justify-content: space-between; - align-items: center; -} - -.inbox-meta { - color: #7f8c8d; - font-size: 0.9rem; -} - -.inbox-actions { - display: flex; - gap: 0.5rem; -} - -.btn-respond { - background: #27ae60; - color: white; -} - -.btn-respond:hover { - background: #229954; -} - -.btn-expand { - background: transparent; - border: 1px solid #3498db; - color: #3498db; -} - -.original-email-box { - background: #f8f9fa; - padding: 1.5rem; - border-left: 4px solid #3498db; - border-radius: 4px; - margin-bottom: 2rem; -} - -.original-email-box h4 { - margin: 0 0 0.5rem 0; - color: #2c3e50; -} - -.original-email-box .email-meta { - color: #7f8c8d; - font-size: 0.9rem; - margin-bottom: 1rem; -} - -.original-email-box .email-body { - color: #2c3e50; - line-height: 1.6; -} - -.modal-large { - max-width: 800px; -} - -.badge-answered { - background: #27ae60; - color: white; -} - -/* Responsive */ -@media (max-width: 768px) { - .rep-stats-grid { - grid-template-columns: 1fr; - } - - .inbox-header { - flex-direction: column; - gap: 1rem; - } - - .inbox-footer { - flex-direction: column; - align-items: flex-start; - gap: 1rem; - } - - .inbox-actions { - width: 100%; - } - - .inbox-actions button { - flex: 1; - } -} -``` - -#### File: `app/public/js/representative-dashboard.js` (NEW FILE) - -```javascript -// Representative Dashboard JavaScript - -let currentOffset = 0; -let currentFilter = 'false'; // Show unanswered by default -const LIMIT = 20; -let isVerified = false; - -// Initialize -document.addEventListener('DOMContentLoaded', async () => { - await checkVerificationStatus(); - loadStats(); - loadInbox(); - - // Event listeners - document.getElementById('status-filter').addEventListener('change', (e) => { - currentFilter = e.target.value; - currentOffset = 0; - loadInbox(true); - }); - - document.getElementById('load-more-btn').addEventListener('click', loadMoreEmails); - document.getElementById('response-form').addEventListener('submit', handleSubmitResponse); -}); - -// Check if representative is verified -async function checkVerificationStatus() { - try { - const response = await fetch('/api/auth/me', { - headers: { - 'Authorization': `Bearer ${localStorage.getItem('token')}` - } - }); - - const data = await response.json(); - - if (data.success && data.user.user_type === 'representative') { - isVerified = data.user.representative_verified; - - if (!isVerified) { - document.getElementById('verification-notice').style.display = 'block'; - } - } else { - window.location.href = '/login.html'; - } - } catch (error) { - console.error('Error checking verification:', error); - window.location.href = '/login.html'; - } -} - -// Load inbox statistics -async function loadStats() { - try { - const response = await fetch('/api/representative/inbox/stats', { - headers: { - 'Authorization': `Bearer ${localStorage.getItem('token')}` - } - }); - - const data = await response.json(); - - if (data.success) { - document.getElementById('total-emails').textContent = data.stats.total_emails; - document.getElementById('unanswered-emails').textContent = data.stats.unanswered; - document.getElementById('answered-emails').textContent = data.stats.answered; - document.getElementById('unique-campaigns').textContent = data.stats.unique_campaigns; - } - } catch (error) { - console.error('Error loading stats:', error); - } -} - -// Load inbox emails -async function loadInbox(reset = false) { - if (reset) { - currentOffset = 0; - document.getElementById('inbox-container').innerHTML = ''; - } - - showLoading(true); - - try { - let url = `/api/representative/inbox?limit=${LIMIT}&offset=${currentOffset}`; - - if (currentFilter !== '') { - url += `&has_response=${currentFilter}`; - } - - const response = await fetch(url, { - headers: { - 'Authorization': `Bearer ${localStorage.getItem('token')}` - } - }); - - const data = await response.json(); - - if (data.success) { - if (data.emails.length === 0 && currentOffset === 0) { - showEmptyState(); - } else { - renderEmails(data.emails); - - const loadMoreContainer = document.getElementById('load-more-container'); - if (data.pagination.hasMore) { - loadMoreContainer.style.display = 'block'; - } else { - loadMoreContainer.style.display = 'none'; - } - } - } else { - showError('Failed to load inbox'); - } - } catch (error) { - console.error('Error loading inbox:', error); - showError('Failed to load inbox'); - } finally { - showLoading(false); - } -} - -// Render inbox emails -function renderEmails(emails) { - const container = document.getElementById('inbox-container'); - document.getElementById('empty-state').style.display = 'none'; - - emails.forEach(email => { - const item = createInboxItem(email); - container.appendChild(item); - }); -} - -// Create inbox item element -function createInboxItem(email) { - const item = document.createElement('div'); - item.className = `inbox-item ${!email.has_response ? 'unread' : 'answered'}`; - item.dataset.emailId = email.Id; - - const date = new Date(email.created_at); - const formattedDate = date.toLocaleDateString('en-US', { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' - }); - - let badges = ''; - if (email.has_response) { - badges = `✓ Answered`; - } - - // Truncate email body for preview - const bodyPreview = email.email_body.length > 200 - ? email.email_body.substring(0, 200) + '...' - : email.email_body; - - item.innerHTML = ` -
-
-

${escapeHtml(email.campaign_title)}

-
- From constituent in ${escapeHtml(email.sender_postal_code)} -
-
-
- ${badges} -
-
- -
- Subject: ${escapeHtml(email.email_subject)}

- ${escapeHtml(bodyPreview)} - ${email.email_body.length > 200 ? '
' : ''} -
- - - `; - - // Store full email data for later use - item.dataset.emailData = JSON.stringify(email); - - return item; -} - -// Toggle email body expansion -function toggleEmailBody(emailId) { - const bodyElement = document.getElementById(`body-${emailId}`); - const item = document.querySelector(`[data-email-id="${emailId}"]`); - const email = JSON.parse(item.dataset.emailData); - - if (bodyElement.classList.contains('expanded')) { - bodyElement.classList.remove('expanded'); - bodyElement.innerHTML = ` - Subject: ${escapeHtml(email.email_subject)}

- ${escapeHtml(email.email_body.substring(0, 200))}... -
- `; - } else { - bodyElement.classList.add('expanded'); - bodyElement.innerHTML = ` - Subject: ${escapeHtml(email.email_subject)}

- ${escapeHtml(email.email_body)} - `; - } -} - -// Open response modal -function openResponseModal(emailId) { - const item = document.querySelector(`[data-email-id="${emailId}"]`); - const email = JSON.parse(item.dataset.emailData); - - document.getElementById('inbox-id').value = email.Id; - - // Show original email in modal - document.getElementById('original-email').innerHTML = ` -

Original Email from Constituent

-
- Campaign: ${escapeHtml(email.campaign_title)}
- From: Constituent in ${escapeHtml(email.sender_postal_code)}
- Subject: ${escapeHtml(email.email_subject)}
- Date: ${new Date(email.created_at).toLocaleDateString()} -
-
- ${escapeHtml(email.email_body)} -
- `; - - document.getElementById('response-modal').style.display = 'block'; -} - -// Close response modal -function closeResponseModal() { - document.getElementById('response-modal').style.display = 'none'; - document.getElementById('response-form').reset(); -} - -// Handle response submission -async function handleSubmitResponse(e) { - e.preventDefault(); - - const inboxId = document.getElementById('inbox-id').value; - const formData = new FormData(); - - formData.append('response_type', document.getElementById('response-type').value); - formData.append('response_text', document.getElementById('response-text').value.trim()); - formData.append('user_comment', document.getElementById('response-comment').value.trim()); - - const screenshotFile = document.getElementById('response-screenshot').files[0]; - if (screenshotFile) { - formData.append('screenshot', screenshotFile); - } - - try { - const submitButton = e.target.querySelector('button[type="submit"]'); - submitButton.disabled = true; - submitButton.textContent = 'Publishing...'; - - const response = await fetch(`/api/representative/respond/${inboxId}`, { - method: 'POST', - headers: { - 'Authorization': `Bearer ${localStorage.getItem('token')}` - }, - body: formData - }); - - const data = await response.json(); - - if (data.success) { - showSuccess('Response published successfully! It now appears on the Response Wall.'); - closeResponseModal(); - loadStats(); - currentOffset = 0; - loadInbox(true); - } else { - showError(data.error || 'Failed to publish response'); - } - } catch (error) { - console.error('Error submitting response:', error); - showError('Failed to publish response'); - } finally { - const submitButton = e.target.querySelector('button[type="submit"]'); - submitButton.disabled = false; - submitButton.textContent = 'Publish Response'; - } -} - -// View existing response -function viewResponse(responseId) { - // Could open response in modal or redirect to response wall - window.open(`/response-wall.html?response=${responseId}`, '_blank'); -} - -// Load more emails -function loadMoreEmails() { - currentOffset += LIMIT; - loadInbox(false); -} - -// Utility functions -function showLoading(show) { - document.getElementById('loading').style.display = show ? 'block' : 'none'; -} - -function showEmptyState() { - document.getElementById('empty-state').style.display = 'block'; - document.getElementById('inbox-container').innerHTML = ''; -} - -function showError(message) { - alert(message); // Could be improved with toast notifications -} - -function showSuccess(message) { - alert(message); // Could be improved with toast notifications -} - -function escapeHtml(text) { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; -} - -function logout() { - localStorage.removeItem('token'); - window.location.href = '/login.html'; -} - -// Close modal when clicking outside -window.onclick = function(event) { - const modal = document.getElementById('response-modal'); - if (event.target === modal) { - closeResponseModal(); - } -}; -``` - -#### File: `app/public/css/response-wall.css` (UPDATE) - -Add representative badge styling: - -```css -/* Add to existing response-wall.css */ - -.badge-verified-rep { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; - padding: 0.25rem 0.75rem; - border-radius: 12px; - font-size: 0.85rem; - font-weight: 600; - display: inline-flex; - align-items: center; - gap: 0.25rem; -} - -.badge-verified-rep::before { - content: "✓"; - font-weight: bold; -} - -.response-card.verified-by-rep { - border: 2px solid #667eea; - background: linear-gradient(to bottom, #f8f9ff 0%, white 100%); -} - -.response-card.verified-by-rep .response-header { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; - margin: -1.5rem -1.5rem 1rem -1.5rem; - padding: 1.5rem; - border-radius: 8px 8px 0 0; -} - -.response-card.verified-by-rep .response-header h3, -.response-card.verified-by-rep .response-header .rep-meta { - color: white; -} - -.response-card.verified-by-rep .response-header .rep-meta { - opacity: 0.9; -} - -.rep-profile-link { - color: #667eea; - text-decoration: none; - font-weight: 600; - display: inline-flex; - align-items: center; - gap: 0.25rem; -} - -.rep-profile-link:hover { - text-decoration: underline; -} -``` - -#### File: `app/public/js/response-wall.js` (UPDATE) - -Update response card rendering to show representative badge: - -```javascript -// Update the createResponseCard function to detect representative responses - -function createResponseCard(response) { - const card = document.createElement('div'); - - // Add special class for representative responses - card.className = response.posted_by_representative - ? 'response-card verified-by-rep' - : 'response-card'; - - card.dataset.responseId = response.Id; - - // ... existing code ... - - // Build badges - add representative badge first if applicable - let badges = ''; - if (response.posted_by_representative) { - badges = `Official Response`; - } - if (response.verified && !response.posted_by_representative) { - badges += `✓ Verified`; - } - badges += `${response.representative_level}`; - badges += `${response.response_type}`; - - // ... rest of existing code ... -} -``` - ---- - -### Admin Panel Updates - -#### File: `app/public/admin.html` (UPDATE) - -Add representative verification section: - -```html - - -``` - -#### File: `app/public/js/admin.js` (UPDATE) - -Add representative verification functions: - -```javascript -// Add these functions to existing admin.js - -async function loadRepresentatives() { - const status = document.getElementById('rep-verification-status').value; - - try { - const response = await fetch(`/api/admin/representatives?status=${status}`, { - headers: { - 'Authorization': `Bearer ${localStorage.getItem('token')}` - } - }); - - const data = await response.json(); - - if (data.success) { - renderRepresentatives(data.representatives); - } - } catch (error) { - console.error('Error loading representatives:', error); - } -} - -function renderRepresentatives(reps) { - const container = document.getElementById('admin-representatives-container'); - - if (reps.length === 0) { - container.innerHTML = '

No representatives found.

'; - return; - } - - container.innerHTML = reps.map(rep => ` -
-

${rep.representative_name} (${rep.representative_level})

-

District: ${rep.representative_district}

-

Email: ${rep.email}

-

Status: ${rep.representative_verified ? 'Verified' : 'Pending Verification'}

- ${rep.verification_document ? ` -

View Verification Document

- ` : ''} - -
- ${!rep.representative_verified ? ` - - ` : ` - - `} -
-
- `).join(''); -} - -async function verifyRepresentative(id) { - if (!confirm('Verify this representative account? They will be able to post official responses.')) { - return; - } - - try { - const response = await fetch(`/api/admin/representatives/${id}/verify`, { - method: 'PATCH', - headers: { - 'Authorization': `Bearer ${localStorage.getItem('token')}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ verified: true }) - }); - - const data = await response.json(); - - if (data.success) { - showSuccess('Representative verified successfully'); - loadRepresentatives(); - } - } catch (error) { - console.error('Error verifying representative:', error); - } -} - -async function unverifyRepresentative(id) { - if (!confirm('Revoke verification for this representative?')) { - return; - } - - try { - const response = await fetch(`/api/admin/representatives/${id}/verify`, { - method: 'PATCH', - headers: { - 'Authorization': `Bearer ${localStorage.getItem('token')}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ verified: false }) - }); - - const data = await response.json(); - - if (data.success) { - showSuccess('Verification revoked'); - loadRepresentatives(); - } - } catch (error) { - console.error('Error revoking verification:', error); - } -} -``` - -#### File: `app/controllers/representativeController.js` (UPDATE) - -Add admin endpoints: - -```javascript -// Add these admin functions to representative controller - -async function getRepresentativesForAdmin(req, res) { - try { - const { status = 'pending' } = req.query; - - let whereClause = `(user_type,eq,representative)`; - - if (status === 'pending') { - whereClause += `~and(representative_verified,eq,false)`; - } else if (status === 'verified') { - whereClause += `~and(representative_verified,eq,true)`; - } - - const reps = await nocodbService.getRecords('users', { - where: whereClause, - sort: '-created_at' - }); - - res.json({ success: true, representatives: reps }); - } catch (error) { - console.error('Error fetching representatives:', error); - res.status(500).json({ error: 'Failed to fetch representatives' }); - } -} - -async function verifyRepresentative(req, res) { - try { - const { id } = req.params; - const { verified } = req.body; - - await nocodbService.updateRecord('users', id, { - representative_verified: verified - }); - - // Could send email notification here - - res.json({ success: true, message: `Representative ${verified ? 'verified' : 'unverified'} successfully` }); - } catch (error) { - console.error('Error updating representative verification:', error); - res.status(500).json({ error: 'Failed to update verification' }); - } -} - -// Export these -module.exports = { - // ... existing exports - getRepresentativesForAdmin, - verifyRepresentative -}; -``` - -#### File: `app/routes/api.js` (UPDATE) - -Add admin representative routes: - -```javascript -// Add these admin routes -router.get('/admin/representatives', requireAdmin, representativeController.getRepresentativesForAdmin); -router.patch('/admin/representatives/:id/verify', requireAdmin, representativeController.verifyRepresentative); -``` - ---- - -### Testing Checklist - -#### Representative Features -- [ ] Representative can register account -- [ ] Representative can request verification -- [ ] Admin can verify/unverify representatives -- [ ] Representative dashboard loads correctly -- [ ] Inbox shows emails sent to representative -- [ ] Representative can respond to emails -- [ ] Responses get "Official Response" badge -- [ ] Responses are auto-verified and auto-approved -- [ ] Inbox tracking works (has_response flag updates) -- [ ] Stats display correctly - -#### Integration -- [ ] Regular users see representative responses with special badge -- [ ] Representative responses appear at top when sorted -- [ ] Profile links work -- [ ] Representative bio displays -- [ ] Verification status prevents unverified reps from responding - ---- - -## Future Enhancements (Post-MVP) - -1. **Representative Scorecards**: Aggregate response data to show which reps are most responsive -2. **Email Notifications**: Notify users when their response is approved -3. **Response Templates**: Let users use parts of responses as email templates -4. **Search Functionality**: Search responses by keyword -5. **Response Analytics**: Track response sentiment, common themes -6. **API Rate Limiting**: Add specific rate limits for response submission -7. **Spam Detection**: Automated spam/abuse detection -8. **Response Categories**: Tag responses (Positive, Negative, Non-Answer, etc.) -9. **Representative Notifications**: Email reps when they receive new constituent emails -10. **Response Drafts**: Let representatives save draft responses -11. **Bulk Response**: Allow representatives to respond to multiple similar emails at once -12. **Representative Analytics**: Show reps their response rate and engagement metrics - ---- - -## Notes for Implementation Agent - -- Follow existing code style and patterns from the project -- Use existing error handling and validation patterns -- Integrate with existing authentication system -- Match existing CSS design patterns -- Test thoroughly before marking complete -- Update README.md with new feature documentation -- Add comments for complex logic - -Good luck! 🚀 diff --git a/influence/RESPONSE_WALL_USAGE.md b/influence/RESPONSE_WALL_USAGE.md deleted file mode 100644 index ba6faf9..0000000 --- a/influence/RESPONSE_WALL_USAGE.md +++ /dev/null @@ -1,204 +0,0 @@ -# Response Wall - Usage Guide - -## Overview -The Response Wall is now fully implemented! It allows campaign participants to share and vote on responses they receive from elected representatives. - -## How to Access the Response Wall - -The Response Wall requires a campaign slug to function. You must access it with a URL parameter: - -### URL Format -``` -http://localhost:3333/response-wall.html?campaign=YOUR-CAMPAIGN-SLUG -``` - -### Example URLs -``` -http://localhost:3333/response-wall.html?campaign=climate-action -http://localhost:3333/response-wall.html?campaign=healthcare-reform -http://localhost:3333/response-wall.html?campaign=education-funding -``` - -## Setup Instructions - -### 1. Create a Campaign First -Before using the Response Wall, you need to have an active campaign: - -1. Go to http://localhost:3333/admin.html -2. Create a new campaign with a slug (e.g., "climate-action") -3. Note the campaign slug you created - -### 2. Access the Response Wall -Use the campaign slug in the URL: -``` -http://localhost:3333/response-wall.html?campaign=YOUR-SLUG-HERE -``` - -## Features - -### For Public Users -- **View Responses**: See all approved responses from representatives -- **Filter & Sort**: - - Filter by government level (Federal, Provincial, Municipal, School Board) - - Sort by Most Recent, Most Upvoted, or Verified First -- **Submit Responses**: Share responses you've received from representatives - - Required: Representative name, level, response type, response text - - Optional: Representative title, your comment, screenshot, your name/email - - Can post anonymously -- **Upvote Responses**: Show appreciation for helpful responses -- **View Statistics**: See total responses, verified count, and total upvotes - -### For Administrators -Access via the admin panel at http://localhost:3333/admin.html: - -1. Navigate to the "Response Moderation" tab -2. Filter by status: Pending, Approved, Rejected, or All -3. Moderate submissions: - - **Approve**: Make response visible to public - - **Reject**: Hide inappropriate responses - - **Verify**: Add verification badge to authentic responses - - **Edit**: Modify response content if needed - - **Delete**: Remove responses permanently - -## Moderation Workflow - -1. User submits a response (status: "pending") -2. Admin reviews in admin panel → "Response Moderation" tab -3. Admin approves → Response appears on public Response Wall -4. Admin can mark as "verified" for authenticity badge -5. Public can upvote helpful responses - -## Integration with Campaigns - -### Link from Campaign Pages -You can add links to the Response Wall from your campaign pages: - -```html - - View Community Responses - -``` - -### Embed in Campaign Flow -Consider adding a "Share Your Response" call-to-action after users send emails to representatives. - -## Testing the Feature - -### 1. Create Test Campaign -```bash -# Via admin panel or API -POST /api/admin/campaigns -{ - "title": "Test Campaign", - "slug": "test-campaign", - "email_subject": "Test", - "email_body": "Test body" -} -``` - -### 2. Submit Test Response -Navigate to: -``` -http://localhost:3333/response-wall.html?campaign=test-campaign -``` - -Click "Share a Response" and fill out the form. - -### 3. Moderate Response -1. Go to admin panel: http://localhost:3333/admin.html -2. Click "Response Moderation" tab -3. Change filter to "Pending" -4. Approve the test response - -### 4. View Public Response -Refresh the Response Wall page to see your approved response. - -## API Endpoints - -### Public Endpoints -- `GET /api/campaigns/:slug/responses` - Get approved responses -- `GET /api/campaigns/:slug/response-stats` - Get statistics -- `POST /api/campaigns/:slug/responses` - Submit new response -- `POST /api/responses/:id/upvote` - Upvote a response -- `DELETE /api/responses/:id/upvote` - Remove upvote - -### Admin Endpoints (require authentication) -- `GET /api/admin/responses` - Get all responses (any status) -- `PATCH /api/admin/responses/:id/status` - Update status -- `PATCH /api/admin/responses/:id` - Update response details -- `DELETE /api/admin/responses/:id` - Delete response - -## Database Tables - -The feature uses two new NocoDB tables: - -### `influence_representative_responses` -Stores submitted responses with fields: -- Representative details (name, title, level) -- Response content (type, text, screenshot) -- Submitter info (name, email, anonymous flag) -- Moderation (status, verified flag) -- Engagement (upvote count) - -### `influence_response_upvotes` -Tracks upvotes with fields: -- Response ID -- User ID / Email (if authenticated) -- IP address (for anonymous upvotes) - -## Troubleshooting - -### "No campaign specified" Error -**Problem**: Accessing `/response-wall.html` without campaign parameter - -**Solution**: Add `?campaign=YOUR-SLUG` to the URL - -### No Responses Showing -**Problem**: Responses exist but not visible - -**Possible Causes**: -1. Responses are in "pending" status (not yet approved by admin) -2. Wrong campaign slug in URL -3. Responses filtered out by current filter settings - -**Solution**: -- Check admin panel → Response Moderation tab -- Verify campaign slug matches -- Clear filters (set to "All Levels") - -### Button Not Working -**Problem**: "Share a Response" button doesn't open modal - -**Solution**: -- Check browser console for JavaScript errors -- Verify you're accessing with a valid campaign slug -- Hard refresh the page (Ctrl+F5) - -### Images Not Uploading -**Problem**: Screenshot upload fails - -**Possible Causes**: -1. File too large (max 5MB) -2. Wrong file type (only JPEG/PNG/GIF/WebP allowed) -3. Upload directory permissions - -**Solution**: -- Resize image to under 5MB -- Convert to supported format -- Check `/app/public/uploads/responses/` directory exists with write permissions - -## Next Steps - -1. **Run Database Setup**: `./scripts/build-nocodb.sh` (if not already done) -2. **Restart Application**: `docker compose down && docker compose up --build` -3. **Create Test Campaign**: Via admin panel -4. **Test Response Submission**: Submit and moderate a test response -5. **Integrate with Campaigns**: Add Response Wall links to your campaign pages - -## Notes - -- All submissions require admin moderation (status: "pending" → "approved") -- Anonymous upvotes are tracked by IP address to prevent duplicates -- Authenticated upvotes are tracked by user ID -- Screenshots are stored in `/app/public/uploads/responses/` -- The feature respects NocoDB best practices (column titles, system fields, etc.) diff --git a/influence/app/controllers/campaigns.js b/influence/app/controllers/campaigns.js index e184c0e..21a284a 100644 --- a/influence/app/controllers/campaigns.js +++ b/influence/app/controllers/campaigns.js @@ -144,6 +144,7 @@ class CampaignsController { cover_photo: campaign['Cover Photo'] || campaign.cover_photo, show_email_count: showEmailCount, show_call_count: showCallCount, + show_response_wall: campaign['Show Response Wall Button'] || campaign.show_response_wall, target_government_levels: normalizedTargetLevels, created_at: campaign.CreatedAt || campaign.created_at, emailCount, @@ -198,6 +199,7 @@ class CampaignsController { collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info, show_email_count: campaign['Show Email Count'] || campaign.show_email_count, allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing, + show_response_wall: campaign['Show Response Wall Button'] || campaign.show_response_wall, target_government_levels: normalizedTargetLevels, created_at: campaign.CreatedAt || campaign.created_at, updated_at: campaign.UpdatedAt || campaign.updated_at, @@ -280,6 +282,7 @@ class CampaignsController { collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info, show_email_count: campaign['Show Email Count'] || campaign.show_email_count, allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing, + show_response_wall: campaign['Show Response Wall Button'] || campaign.show_response_wall, target_government_levels: normalizeTargetLevels(campaign['Target Government Levels'] || campaign.target_government_levels), created_at: campaign.CreatedAt || campaign.created_at, updated_at: campaign.UpdatedAt || campaign.updated_at, @@ -366,6 +369,7 @@ class CampaignsController { show_email_count: campaign['Show Email Count'] || campaign.show_email_count, show_call_count: campaign['Show Call Count'] || campaign.show_call_count, allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing, + show_response_wall: campaign['Show Response Wall Button'] || campaign.show_response_wall, target_government_levels: normalizeTargetLevels(campaign['Target Government Levels'] || campaign.target_government_levels), emailCount, callCount diff --git a/influence/app/public/admin.html b/influence/app/public/admin.html index 24c5ee2..3508fc1 100644 --- a/influence/app/public/admin.html +++ b/influence/app/public/admin.html @@ -918,6 +918,10 @@ Sincerely, +
+ + +
@@ -1035,6 +1039,10 @@ Sincerely, +
+ + +
diff --git a/influence/app/public/campaign.html b/influence/app/public/campaign.html index 598e674..b6c0090 100644 --- a/influence/app/public/campaign.html +++ b/influence/app/public/campaign.html @@ -48,20 +48,86 @@ text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.7); } - .campaign-stats { - background: #f8f9fa; - padding: 1rem; - border-radius: 8px; - text-align: center; - margin-bottom: 2rem; - border: 2px solid #e9ecef; + .campaign-header-content { + max-width: 800px; + margin: 0 auto; } - .email-count { - font-size: 2rem; + .campaign-stats-header { + display: flex; + gap: 1.5rem; + justify-content: center; + margin-top: 1.5rem; + flex-wrap: wrap; + } + + .stat-circle { + background: rgba(255, 255, 255, 0.2); + border: 2px solid rgba(255, 255, 255, 0.3); + border-radius: 50%; + width: 80px; + height: 80px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + backdrop-filter: blur(10px); + } + + .stat-number { + font-size: 1.8rem; font-weight: bold; - color: #3498db; - margin-bottom: 0.5rem; + color: white; + line-height: 1; + text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3); + } + + .stat-label { + font-size: 0.7rem; + color: rgba(255, 255, 255, 0.9); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-top: 0.25rem; + text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.3); + } + + .share-buttons-header { + display: flex; + gap: 0.5rem; + justify-content: center; + margin-top: 1rem; + flex-wrap: wrap; + } + + .share-btn-small { + background: rgba(255, 255, 255, 0.2); + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 50%; + width: 36px; + height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: all 0.2s; + backdrop-filter: blur(10px); + } + + .share-btn-small:hover { + background: rgba(255, 255, 255, 0.3); + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); + } + + .share-btn-small svg { + width: 18px; + height: 18px; + fill: white; + } + + .share-btn-small.copied { + background: rgba(40, 167, 69, 0.8); + border-color: rgba(40, 167, 69, 1); } .campaign-content { @@ -144,8 +210,13 @@ display: none; } + .preview-mode .email-preview-actions { + display: block !important; + } + .edit-mode .email-subject, - .edit-mode .email-body { + .edit-mode .email-body, + .edit-mode .email-preview-actions { display: none; } @@ -265,6 +336,86 @@ text-align: center; } + /* Response Wall Button Styles */ + .response-wall-button { + display: inline-block; + padding: 1rem 2rem; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + text-decoration: none; + border-radius: 50px; + font-size: 1.1rem; + font-weight: bold; + text-align: center; + box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4); + position: relative; + overflow: hidden; + transition: transform 0.3s, box-shadow 0.3s; + animation: pulse-glow 2s ease-in-out infinite; + } + + .response-wall-button:hover { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6); + animation: none; + } + + .response-wall-button::before { + content: ''; + position: absolute; + top: -50%; + left: -50%; + width: 200%; + height: 200%; + background: linear-gradient( + 45deg, + transparent, + rgba(255, 255, 255, 0.1), + transparent + ); + transform: rotate(45deg); + animation: shine 3s ease-in-out infinite; + } + + @keyframes pulse-glow { + 0%, 100% { + box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4); + } + 50% { + box-shadow: 0 4px 25px rgba(102, 126, 234, 0.8), 0 0 30px rgba(102, 126, 234, 0.5); + } + } + + @keyframes shine { + 0% { + left: -50%; + } + 100% { + left: 150%; + } + } + + .response-wall-container { + text-align: center; + margin: 2rem 0; + padding: 2rem; + background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%); + border-radius: 12px; + } + + .response-wall-container h3 { + margin: 0 0 1rem 0; + color: #2c3e50; + font-size: 1.3rem; + } + + .response-wall-container p { + margin: 0 0 1.5rem 0; + color: #666; + } + + + @media (max-width: 768px) { .campaign-header h1 { font-size: 2rem; @@ -294,26 +445,60 @@
-

Loading Campaign...

-

+
+

Loading Campaign...

+

+ + + + + + +
- - - + + + + + +
@@ -408,9 +607,22 @@ + \ No newline at end of file diff --git a/influence/app/public/dashboard.html b/influence/app/public/dashboard.html index 370033a..ef93063 100644 --- a/influence/app/public/dashboard.html +++ b/influence/app/public/dashboard.html @@ -744,6 +744,10 @@ Sincerely, +
+ + +
@@ -854,6 +858,10 @@ Sincerely, +
+ + +
diff --git a/influence/app/public/index.html b/influence/app/public/index.html index 69ef37a..a2631c3 100644 --- a/influence/app/public/index.html +++ b/influence/app/public/index.html @@ -31,7 +31,7 @@
- +

© 2025 BNKops Influence Tool. Connect with democracy.

This tool uses the Represent API by Open North to find your representatives.

-

Terms of Use & Privacy Notice

+

Terms of Use & Privacy Notice

Influence is an open-source platform and the code is available to all at gitea.bnkops.com/admin/changemaker.lite

@@ -236,6 +236,17 @@ } } } + + // Update navigation links with APP_URL if needed + fetch('/api/config') + .then(res => res.json()) + .then(config => { + if (config.appUrl && !window.location.href.startsWith(config.appUrl)) { + document.getElementById('terms-link').href = config.appUrl + '/terms.html'; + document.getElementById('login-link').href = config.appUrl + '/login.html'; + } + }) + .catch(err => console.log('Config not loaded, using relative paths')); }); diff --git a/influence/app/public/js/admin.js b/influence/app/public/js/admin.js index 9dcd6be..0366ad9 100644 --- a/influence/app/public/js/admin.js +++ b/influence/app/public/js/admin.js @@ -572,6 +572,7 @@ class AdminPanel { campaignFormData.append('collect_user_info', formData.get('collect_user_info') === 'on'); campaignFormData.append('show_email_count', formData.get('show_email_count') === 'on'); campaignFormData.append('allow_email_editing', formData.get('allow_email_editing') === 'on'); + campaignFormData.append('show_response_wall', formData.get('show_response_wall') === 'on'); // Handle target_government_levels array const targetLevels = Array.from(formData.getAll('target_government_levels')); @@ -643,6 +644,7 @@ class AdminPanel { form.querySelector('[name="collect_user_info"]').checked = campaign.collect_user_info; form.querySelector('[name="show_email_count"]').checked = campaign.show_email_count; form.querySelector('[name="allow_email_editing"]').checked = campaign.allow_email_editing; + form.querySelector('[name="show_response_wall"]').checked = campaign.show_response_wall; // Government levels let targetLevels = []; @@ -679,6 +681,7 @@ class AdminPanel { updateFormData.append('collect_user_info', formData.get('collect_user_info') === 'on'); updateFormData.append('show_email_count', formData.get('show_email_count') === 'on'); updateFormData.append('allow_email_editing', formData.get('allow_email_editing') === 'on'); + updateFormData.append('show_response_wall', formData.get('show_response_wall') === 'on'); // Handle target_government_levels array const targetLevels = Array.from(formData.getAll('target_government_levels')); diff --git a/influence/app/public/js/campaign.js b/influence/app/public/js/campaign.js index baa44dc..ce66c98 100644 --- a/influence/app/public/js/campaign.js +++ b/influence/app/public/js/campaign.js @@ -23,10 +23,90 @@ class CampaignPage { this.formatPostalCode(e); }); + // Set up social share buttons + this.setupShareButtons(); + // Load campaign data this.loadCampaign(); } + setupShareButtons() { + // Get current URL + const shareUrl = window.location.href; + + // Facebook share + document.getElementById('share-facebook')?.addEventListener('click', () => { + const url = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`; + window.open(url, '_blank', 'width=600,height=400'); + }); + + // Twitter share + document.getElementById('share-twitter')?.addEventListener('click', () => { + const text = this.campaign ? `Check out this campaign: ${this.campaign.title}` : 'Check out this campaign'; + const url = `https://twitter.com/intent/tweet?url=${encodeURIComponent(shareUrl)}&text=${encodeURIComponent(text)}`; + window.open(url, '_blank', 'width=600,height=400'); + }); + + // LinkedIn share + document.getElementById('share-linkedin')?.addEventListener('click', () => { + const url = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`; + window.open(url, '_blank', 'width=600,height=400'); + }); + + // WhatsApp share + document.getElementById('share-whatsapp')?.addEventListener('click', () => { + const text = this.campaign ? `Check out this campaign: ${this.campaign.title}` : 'Check out this campaign'; + const url = `https://wa.me/?text=${encodeURIComponent(text + ' ' + shareUrl)}`; + window.open(url, '_blank'); + }); + + // Email share + document.getElementById('share-email')?.addEventListener('click', () => { + const subject = this.campaign ? `Campaign: ${this.campaign.title}` : 'Check out this campaign'; + const body = this.campaign ? + `I thought you might be interested in this campaign:\n\n${this.campaign.title}\n\n${shareUrl}` : + `Check out this campaign:\n\n${shareUrl}`; + window.location.href = `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`; + }); + + // Copy link + document.getElementById('share-copy')?.addEventListener('click', async () => { + const copyBtn = document.getElementById('share-copy'); + + try { + await navigator.clipboard.writeText(shareUrl); + copyBtn.classList.add('copied'); + copyBtn.title = 'Copied!'; + + setTimeout(() => { + copyBtn.classList.remove('copied'); + copyBtn.title = 'Copy Link'; + }, 2000); + } catch (err) { + // Fallback for older browsers + const textArea = document.createElement('textarea'); + textArea.value = shareUrl; + textArea.style.position = 'fixed'; + textArea.style.left = '-999999px'; + document.body.appendChild(textArea); + textArea.select(); + try { + document.execCommand('copy'); + copyBtn.classList.add('copied'); + copyBtn.title = 'Copied!'; + setTimeout(() => { + copyBtn.classList.remove('copied'); + copyBtn.title = 'Copy Link'; + }, 2000); + } catch (err) { + console.error('Failed to copy:', err); + alert('Failed to copy link. Please copy manually: ' + shareUrl); + } + document.body.removeChild(textArea); + } + }); + } + async loadCampaign() { this.showLoading('Loading campaign...'); @@ -76,25 +156,27 @@ class CampaignPage { } // Show email count if enabled (show even if count is 0) - const statsSection = document.getElementById('campaign-stats'); + const statsHeaderSection = document.getElementById('campaign-stats-header'); let hasStats = false; if (this.campaign.show_email_count && this.campaign.emailCount !== null && this.campaign.emailCount !== undefined) { - document.getElementById('email-count').textContent = this.campaign.emailCount; - document.getElementById('email-count-container').style.display = 'block'; + // Header stats + document.getElementById('email-count-header').textContent = this.campaign.emailCount; + document.getElementById('email-stat-circle').style.display = 'flex'; hasStats = true; } // Show call count if enabled (show even if count is 0) if (this.campaign.show_call_count && this.campaign.callCount !== null && this.campaign.callCount !== undefined) { - document.getElementById('call-count').textContent = this.campaign.callCount; - document.getElementById('call-count-container').style.display = 'block'; + // Header stats + document.getElementById('call-count-header').textContent = this.campaign.callCount; + document.getElementById('call-stat-circle').style.display = 'flex'; hasStats = true; } // Show stats section if any stat is enabled if (hasStats) { - statsSection.style.display = 'block'; + statsHeaderSection.style.display = 'flex'; } // Show call to action @@ -103,6 +185,16 @@ class CampaignPage { document.getElementById('call-to-action').style.display = 'block'; } + // Show response wall button if enabled + if (this.campaign.show_response_wall) { + const responseWallSection = document.getElementById('response-wall-section'); + const responseWallLink = document.getElementById('response-wall-link'); + if (responseWallSection && responseWallLink) { + responseWallLink.href = `/response-wall.html?campaign=${this.campaignSlug}`; + responseWallSection.style.display = 'block'; + } + } + // Set up email preview this.setupEmailPreview(); @@ -198,6 +290,7 @@ class CampaignPage { const editBody = document.getElementById('edit-body'); const previewBtn = document.getElementById('preview-email-btn'); const saveBtn = document.getElementById('save-email-btn'); + const editBtn = document.getElementById('edit-email-btn'); // Auto-update current content as user types editSubject.addEventListener('input', (e) => { @@ -208,15 +301,46 @@ class CampaignPage { this.currentEmailBody = e.target.value; }); - // Preview button - toggle between edit and preview mode + // Preview button - switch to preview mode previewBtn.addEventListener('click', () => { - this.toggleEmailPreview(); + this.showEmailPreview(); }); - // Save button - save changes + // Save button - save changes and show preview saveBtn.addEventListener('click', () => { this.saveEmailChanges(); }); + + // Edit button - switch back to edit mode + if (editBtn) { + editBtn.addEventListener('click', () => { + this.showEmailEditor(); + }); + } + } + + showEmailPreview() { + const emailPreview = document.getElementById('email-preview'); + + // Update preview content + document.getElementById('preview-subject').textContent = this.currentEmailSubject; + document.getElementById('preview-body').textContent = this.currentEmailBody; + + // Switch to preview mode + emailPreview.classList.remove('edit-mode'); + emailPreview.classList.add('preview-mode'); + } + + showEmailEditor() { + const emailPreview = document.getElementById('email-preview'); + + // Update edit fields with current content + document.getElementById('edit-subject').value = this.currentEmailSubject; + document.getElementById('edit-body').value = this.currentEmailBody; + + // Switch to edit mode + emailPreview.classList.remove('preview-mode'); + emailPreview.classList.add('edit-mode'); } toggleEmailPreview() { @@ -240,10 +364,13 @@ class CampaignPage { } saveEmailChanges() { - // Update the current values and show confirmation + // Update preview content document.getElementById('preview-subject').textContent = this.currentEmailSubject; document.getElementById('preview-body').textContent = this.currentEmailBody; + // Switch to preview mode + this.showEmailPreview(); + // Show success message this.showMessage('Email content updated successfully!', 'success'); @@ -640,9 +767,12 @@ class CampaignPage { showSuccess(message) { // Update email count if enabled if (this.campaign.show_email_count) { - const countElement = document.getElementById('email-count'); - const currentCount = parseInt(countElement.textContent) || 0; - countElement.textContent = currentCount + 1; + const countHeaderElement = document.getElementById('email-count-header'); + const currentCount = parseInt(countHeaderElement?.textContent) || 0; + const newCount = currentCount + 1; + if (countHeaderElement) { + countHeaderElement.textContent = newCount; + } } // You could show a toast or update UI to indicate success @@ -652,9 +782,12 @@ class CampaignPage { showCallSuccess(message) { // Update call count if enabled if (this.campaign.show_call_count) { - const countElement = document.getElementById('call-count'); - const currentCount = parseInt(countElement.textContent) || 0; - countElement.textContent = currentCount + 1; + const countHeaderElement = document.getElementById('call-count-header'); + const currentCount = parseInt(countHeaderElement?.textContent) || 0; + const newCount = currentCount + 1; + if (countHeaderElement) { + countHeaderElement.textContent = newCount; + } } // Show success message diff --git a/influence/app/public/js/dashboard.js b/influence/app/public/js/dashboard.js index e7d6b1e..9d0c41e 100644 --- a/influence/app/public/js/dashboard.js +++ b/influence/app/public/js/dashboard.js @@ -1,4 +1,4 @@ -// User Dashboard JavaScript + // User Dashboard JavaScript class UserDashboard { constructor() { this.user = null; @@ -871,6 +871,7 @@ class UserDashboard { form.querySelector('[name="collect_user_info"]').checked = !!campaign.collect_user_info; form.querySelector('[name="show_email_count"]').checked = !!campaign.show_email_count; form.querySelector('[name="allow_email_editing"]').checked = !!campaign.allow_email_editing; + form.querySelector('[name="show_response_wall"]').checked = !!campaign.show_response_wall; // Government levels const targetLevels = Array.isArray(campaign.target_government_levels) @@ -900,6 +901,7 @@ class UserDashboard { collect_user_info: formData.get('collect_user_info') === 'on', show_email_count: formData.get('show_email_count') === 'on', allow_email_editing: formData.get('allow_email_editing') === 'on', + show_response_wall: formData.get('show_response_wall') === 'on', target_government_levels: Array.from(formData.getAll('target_government_levels')) }; @@ -1237,6 +1239,7 @@ class UserDashboard { collect_user_info: formData.get('collect_user_info') === 'on', show_email_count: formData.get('show_email_count') === 'on', allow_email_editing: formData.get('allow_email_editing') === 'on', + show_response_wall: formData.get('show_response_wall') === 'on', target_government_levels: Array.from(formData.getAll('target_government_levels')) }; diff --git a/influence/app/public/login.html b/influence/app/public/login.html index 1223ae3..4eff98d 100644 --- a/influence/app/public/login.html +++ b/influence/app/public/login.html @@ -173,12 +173,23 @@
+ \ No newline at end of file diff --git a/influence/app/public/terms.html b/influence/app/public/terms.html index 75b9707..1f9000a 100644 --- a/influence/app/public/terms.html +++ b/influence/app/public/terms.html @@ -223,8 +223,20 @@ + + \ No newline at end of file diff --git a/influence/app/server.js b/influence/app/server.js index 1046f24..311f963 100644 --- a/influence/app/server.js +++ b/influence/app/server.js @@ -52,6 +52,13 @@ app.use(express.static(path.join(__dirname, 'public'))); app.use('/api/auth', authRoutes); app.use('/api', apiRoutes); +// Config endpoint - expose APP_URL to client +app.get('/api/config', (req, res) => { + res.json({ + appUrl: process.env.APP_URL || `http://localhost:${PORT}` + }); +}); + // Serve the main page app.get('/', (req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); diff --git a/influence/app/services/nocodb.js b/influence/app/services/nocodb.js index 59f67ad..0c87bc1 100644 --- a/influence/app/services/nocodb.js +++ b/influence/app/services/nocodb.js @@ -436,6 +436,7 @@ class NocoDBService { 'Collect User Info': campaignData.collect_user_info, 'Show Email Count': campaignData.show_email_count, 'Allow Email Editing': campaignData.allow_email_editing, + 'Show Response Wall Button': campaignData.show_response_wall, 'Target Government Levels': campaignData.target_government_levels, 'Created By User ID': campaignData.created_by_user_id, 'Created By User Email': campaignData.created_by_user_email, @@ -468,6 +469,7 @@ class NocoDBService { if (updates.collect_user_info !== undefined) mappedUpdates['Collect User Info'] = updates.collect_user_info; if (updates.show_email_count !== undefined) mappedUpdates['Show Email Count'] = updates.show_email_count; if (updates.allow_email_editing !== undefined) mappedUpdates['Allow Email Editing'] = updates.allow_email_editing; + if (updates.show_response_wall !== undefined) mappedUpdates['Show Response Wall Button'] = updates.show_response_wall; if (updates.target_government_levels !== undefined) mappedUpdates['Target Government Levels'] = updates.target_government_levels; if (updates.updated_at !== undefined) mappedUpdates['UpdatedAt'] = updates.updated_at; diff --git a/influence/scripts/build-nocodb.sh b/influence/scripts/build-nocodb.sh index 2a32326..1e8bb2f 100755 --- a/influence/scripts/build-nocodb.sh +++ b/influence/scripts/build-nocodb.sh @@ -1081,6 +1081,12 @@ create_campaigns_table() { "uidt": "Checkbox", "cdf": "false" }, + { + "column_name": "show_response_wall", + "title": "Show Response Wall Button", + "uidt": "Checkbox", + "cdf": "false" + }, { "column_name": "target_government_levels", "title": "Target Government Levels",