diff --git a/influence/ADMIN_INLINE_HANDLER_FIX.md b/influence/ADMIN_INLINE_HANDLER_FIX.md new file mode 100644 index 0000000..880dc7d --- /dev/null +++ b/influence/ADMIN_INLINE_HANDLER_FIX.md @@ -0,0 +1,191 @@ +# 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/README.MD b/influence/README.MD index 748395d..73e4aea 100644 --- a/influence/README.MD +++ b/influence/README.MD @@ -10,6 +10,7 @@ A comprehensive web application that helps Alberta residents connect with their - **Direct Email**: Built-in email composer to contact representatives - **Campaign Management**: Create and manage advocacy campaigns with customizable settings - **Public Campaigns Grid**: Homepage display of all active campaigns for easy discovery and participation +- **Response Wall**: Community-driven platform for sharing and voting on representative responses - **Email Count Display**: Optional engagement metrics showing total emails sent per campaign - **Smart Caching**: Fast performance with NocoDB caching and graceful fallback to live API - **Responsive Design**: Works seamlessly on desktop and mobile devices @@ -406,6 +407,33 @@ influence/ - **Target Levels**: Select which government levels to target (Federal/Provincial/Municipal/School Board) - **Campaign Status**: Draft, Active, Paused, or Archived workflow states +### Response Wall Feature +The Response Wall creates transparency and accountability by allowing campaign participants to share responses they receive from elected representatives. + +**Key Features:** +- **Public Response Sharing**: Constituents can post responses received via email, letter, phone, meetings, or social media +- **Community Voting**: Upvote system highlights helpful and representative responses +- **Verification System**: Admin-moderated verification badges for authentic responses +- **Screenshot Support**: Upload visual proof of responses (images up to 5MB) +- **Anonymous Posting**: Option to share responses without revealing identity +- **Filtering & Sorting**: Filter by government level, sort by recent/upvotes/verified +- **Engagement Statistics**: Track total responses, verified count, and community upvotes +- **Moderation Queue**: Admin panel for approving, rejecting, or verifying submitted responses +- **Campaign Integration**: Response walls linked to specific campaigns for contextualized feedback + +**Access Response Wall:** +- Via campaign page: Add `?campaign=your-campaign-slug` parameter to `/response-wall.html` +- Admin moderation: Navigate to "Response Moderation" tab in admin panel +- Public viewing: All approved responses visible to encourage participation + +**Moderation Workflow:** +1. Users submit responses with required details (representative name, level, type, response text) +2. Submissions enter "pending" status in moderation queue +3. Admins review and approve/reject from admin panel +4. Approved responses appear on public Response Wall +5. Admins can mark verified responses with special badge +6. Community upvotes highlight most impactful responses + ### Email Integration - Modal-based email composer - Pre-filled recipient information diff --git a/influence/RESPONSE_WALL_FIXES.md b/influence/RESPONSE_WALL_FIXES.md new file mode 100644 index 0000000..5bf05f5 --- /dev/null +++ b/influence/RESPONSE_WALL_FIXES.md @@ -0,0 +1,181 @@ +# 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 new file mode 100644 index 0000000..ba6faf9 --- /dev/null +++ b/influence/RESPONSE_WALL_USAGE.md @@ -0,0 +1,204 @@ +# 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/responses.js b/influence/app/controllers/responses.js new file mode 100644 index 0000000..12f865f --- /dev/null +++ b/influence/app/controllers/responses.js @@ -0,0 +1,467 @@ +const nocodbService = require('../services/nocodb'); +const { validateResponse } = require('../utils/validators'); + +/** + * Get all responses for a campaign + * Public endpoint - no authentication required + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +async function getCampaignResponses(req, res) { + try { + const { slug } = req.params; + const { sort = 'recent', level = '', offset = 0, limit = 20, status = 'approved' } = req.query; + + // Get campaign by slug first + const campaign = await nocodbService.getCampaignBySlug(slug); + if (!campaign) { + return res.status(404).json({ error: 'Campaign not found' }); + } + + // Build filter conditions + // NocoDB v2 API requires ~and operator between multiple conditions + let whereConditions = [ + `(Campaign Slug,eq,${slug})~and(Status,eq,${status})` + ]; + + if (level) { + whereConditions[0] += `~and(Representative Level,eq,${level})`; + } + + // Build sort order + let sortOrder = '-CreatedAt'; // Default to most recent + if (sort === 'upvotes') { + sortOrder = '-Upvote Count'; + } else if (sort === 'verified') { + sortOrder = '-Is Verified,-CreatedAt'; + } + + // Fetch responses + const responses = await nocodbService.getRepresentativeResponses({ + where: whereConditions[0], // Use the first (and only) element + sort: sortOrder, + offset: parseInt(offset), + limit: parseInt(limit) + }); + + console.log(`Found ${responses.length} responses for campaign ${slug} with status ${status}`); + if (responses.length > 0) { + console.log('First response sample:', JSON.stringify(responses[0], null, 2)); + } + + // For each response, check if current user has upvoted + const userId = req.user?.id; + const userEmail = req.user?.email; + + const responsesWithUpvoteStatus = await Promise.all(responses.map(async (response) => { + let hasUpvoted = false; + + if (userId || userEmail) { + const upvotes = await nocodbService.getResponseUpvotes({ + where: `(Response ID,eq,${response.id})` + }); + + hasUpvoted = upvotes.some(upvote => + (userId && upvote.user_id === userId) || + (userEmail && upvote.user_email === userEmail) + ); + } + + return { + ...response, + hasUpvoted + }; + })); + + res.json({ + success: true, + responses: responsesWithUpvoteStatus, + pagination: { + offset: parseInt(offset), + limit: parseInt(limit), + hasMore: responses.length === parseInt(limit) + } + }); + + } catch (error) { + console.error('Error getting campaign responses:', error); + res.status(500).json({ error: 'Failed to get campaign responses' }); + } +} + +/** + * Submit a new response + * Optional authentication - allows anonymous submissions + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +async function submitResponse(req, res) { + try { + const { slug } = req.params; + const responseData = req.body; + + // Get campaign by slug first + const campaign = await nocodbService.getCampaignBySlug(slug); + if (!campaign) { + return res.status(404).json({ error: 'Campaign not found' }); + } + + // Validate response data + const validation = validateResponse(responseData); + if (!validation.valid) { + return res.status(400).json({ error: validation.error }); + } + + // Handle file upload if present + let screenshotUrl = null; + if (req.file) { + screenshotUrl = `/uploads/responses/${req.file.filename}`; + } + + // Prepare response data for NocoDB + const newResponse = { + campaign_id: campaign.ID || campaign.Id || campaign.id || campaign['Campaign ID'], + campaign_slug: slug, + representative_name: responseData.representative_name, + representative_title: responseData.representative_title || null, + representative_level: responseData.representative_level, + response_type: responseData.response_type, + response_text: responseData.response_text, + user_comment: responseData.user_comment || null, + screenshot_url: screenshotUrl, + submitted_by_name: responseData.submitted_by_name || null, + submitted_by_email: responseData.submitted_by_email || null, + submitted_by_user_id: req.user?.id || null, + is_anonymous: responseData.is_anonymous || false, + status: 'pending', // All submissions start as pending + is_verified: false, + upvote_count: 0, + submitted_ip: req.ip || req.connection.remoteAddress + }; + + console.log('Submitting response with campaign_id:', newResponse.campaign_id, 'from campaign:', campaign); + + // Create response in database + const createdResponse = await nocodbService.createRepresentativeResponse(newResponse); + + res.status(201).json({ + success: true, + message: 'Response submitted successfully. It will be visible after moderation.', + response: createdResponse + }); + + } catch (error) { + console.error('Error submitting response:', error); + res.status(500).json({ error: 'Failed to submit response' }); + } +} + +/** + * Upvote a response + * Optional authentication - allows anonymous upvotes with IP tracking + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +async function upvoteResponse(req, res) { + try { + const { id } = req.params; + + // Get response + const response = await nocodbService.getRepresentativeResponseById(id); + if (!response) { + return res.status(404).json({ error: 'Response not found' }); + } + + const userId = req.user?.id; + const userEmail = req.user?.email; + const userIp = req.ip || req.connection.remoteAddress; + + // Check if already upvoted + const existingUpvotes = await nocodbService.getResponseUpvotes({ + where: `(Response ID,eq,${id})` + }); + + const alreadyUpvoted = existingUpvotes.some(upvote => + (userId && upvote.user_id === userId) || + (userEmail && upvote.user_email === userEmail) || + (upvote.upvoted_ip === userIp) + ); + + if (alreadyUpvoted) { + return res.status(400).json({ error: 'You have already upvoted this response' }); + } + + // Create upvote + await nocodbService.createResponseUpvote({ + response_id: id, + user_id: userId || null, + user_email: userEmail || null, + upvoted_ip: userIp + }); + + // Increment upvote count + const newCount = (response.upvote_count || 0) + 1; + await nocodbService.updateRepresentativeResponse(id, { + upvote_count: newCount + }); + + res.json({ + success: true, + message: 'Response upvoted successfully', + upvoteCount: newCount + }); + + } catch (error) { + console.error('Error upvoting response:', error); + res.status(500).json({ error: 'Failed to upvote response' }); + } +} + +/** + * Remove upvote from a response + * Optional authentication + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +async function removeUpvote(req, res) { + try { + const { id } = req.params; + + // Get response + const response = await nocodbService.getRepresentativeResponseById(id); + if (!response) { + return res.status(404).json({ error: 'Response not found' }); + } + + const userId = req.user?.id; + const userEmail = req.user?.email; + const userIp = req.ip || req.connection.remoteAddress; + + // Find upvote to remove + const upvotes = await nocodbService.getResponseUpvotes({ + where: `(Response ID,eq,${id})` + }); + + const upvoteToRemove = upvotes.find(upvote => + (userId && upvote.user_id === userId) || + (userEmail && upvote.user_email === userEmail) || + (upvote.upvoted_ip === userIp) + ); + + if (!upvoteToRemove) { + return res.status(400).json({ error: 'You have not upvoted this response' }); + } + + // Delete upvote + await nocodbService.deleteResponseUpvote(upvoteToRemove.id); + + // Decrement upvote count + const newCount = Math.max((response.upvote_count || 0) - 1, 0); + await nocodbService.updateRepresentativeResponse(id, { + upvote_count: newCount + }); + + res.json({ + success: true, + message: 'Upvote removed successfully', + upvoteCount: newCount + }); + + } catch (error) { + console.error('Error removing upvote:', error); + res.status(500).json({ error: 'Failed to remove upvote' }); + } +} + +/** + * Get response statistics for a campaign + * Public endpoint + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +async function getResponseStats(req, res) { + try { + const { slug } = req.params; + + // Get campaign by slug first + const campaign = await nocodbService.getCampaignBySlug(slug); + if (!campaign) { + return res.status(404).json({ error: 'Campaign not found' }); + } + + // Get all approved responses for this campaign + const responses = await nocodbService.getRepresentativeResponses({ + where: `(Campaign Slug,eq,${slug}),(Status,eq,approved)` + }); + + // Calculate stats + const totalResponses = responses.length; + const verifiedResponses = responses.filter(r => r.is_verified).length; + const totalUpvotes = responses.reduce((sum, r) => sum + (r.upvote_count || 0), 0); + + // Count by level + const byLevel = { + Federal: responses.filter(r => r.representative_level === 'Federal').length, + Provincial: responses.filter(r => r.representative_level === 'Provincial').length, + Municipal: responses.filter(r => r.representative_level === 'Municipal').length, + 'School Board': responses.filter(r => r.representative_level === 'School Board').length + }; + + res.json({ + success: true, + stats: { + totalResponses, + verifiedResponses, + totalUpvotes, + byLevel + } + }); + + } catch (error) { + console.error('Error getting response stats:', error); + res.status(500).json({ error: 'Failed to get response statistics' }); + } +} + +// Admin functions + +/** + * Get all responses (admin only - includes pending/rejected) + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +async function getAdminResponses(req, res) { + try { + const { status = 'all', offset = 0, limit = 50 } = req.query; + + console.log(`Admin fetching responses with status filter: ${status}`); + + let whereConditions = []; + if (status !== 'all') { + whereConditions.push(`(Status,eq,${status})`); + } + + const responses = await nocodbService.getRepresentativeResponses({ + where: whereConditions.join(','), + sort: '-CreatedAt', + offset: parseInt(offset), + limit: parseInt(limit) + }); + + console.log(`Admin found ${responses.length} total responses`); + if (responses.length > 0) { + console.log('Response statuses:', responses.map(r => `ID:${r.id} Status:${r.status}`).join(', ')); + } + + res.json({ + success: true, + responses, + pagination: { + offset: parseInt(offset), + limit: parseInt(limit), + hasMore: responses.length === parseInt(limit) + } + }); + + } catch (error) { + console.error('Error getting admin responses:', error); + res.status(500).json({ error: 'Failed to get responses' }); + } +} + +/** + * Update response status (admin only) + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +async function updateResponseStatus(req, res) { + try { + const { id } = req.params; + const { status } = req.body; + + console.log(`Updating response ${id} to status: ${status}`); + + if (!['pending', 'approved', 'rejected'].includes(status)) { + return res.status(400).json({ error: 'Invalid status' }); + } + + const updated = await nocodbService.updateRepresentativeResponse(id, { status }); + console.log('Updated response:', JSON.stringify(updated, null, 2)); + + res.json({ + success: true, + message: `Response ${status} successfully` + }); + + } catch (error) { + console.error('Error updating response status:', error); + res.status(500).json({ error: 'Failed to update response status' }); + } +} + +/** + * Update response (admin only) + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +async function updateResponse(req, res) { + try { + const { id } = req.params; + const updates = req.body; + + await nocodbService.updateRepresentativeResponse(id, updates); + + res.json({ + success: true, + message: 'Response updated successfully' + }); + + } catch (error) { + console.error('Error updating response:', error); + res.status(500).json({ error: 'Failed to update response' }); + } +} + +/** + * Delete response (admin only) + * @param {Object} req - Express request object + * @param {Object} res - Express response object + */ +async function deleteResponse(req, res) { + try { + const { id } = req.params; + + // Delete all upvotes for this response first + const upvotes = await nocodbService.getResponseUpvotes({ + where: `(Response ID,eq,${id})` + }); + + for (const upvote of upvotes) { + await nocodbService.deleteResponseUpvote(upvote.id); + } + + // Delete the response + await nocodbService.deleteRepresentativeResponse(id); + + res.json({ + success: true, + message: 'Response deleted successfully' + }); + + } catch (error) { + console.error('Error deleting response:', error); + res.status(500).json({ error: 'Failed to delete response' }); + } +} + +module.exports = { + getCampaignResponses, + submitResponse, + upvoteResponse, + removeUpvote, + getResponseStats, + getAdminResponses, + updateResponseStatus, + updateResponse, + deleteResponse +}; diff --git a/influence/app/middleware/auth.js b/influence/app/middleware/auth.js index a8c9534..39d3ef1 100644 --- a/influence/app/middleware/auth.js +++ b/influence/app/middleware/auth.js @@ -162,8 +162,35 @@ const requireNonTemp = async (req, res, next) => { } }; +// Optional authentication - sets req.user if authenticated, but doesn't block if not +const optionalAuth = async (req, res, next) => { + const isAuthenticated = (req.session && req.session.authenticated) || + (req.session && req.session.userId && req.session.userEmail); + + if (isAuthenticated) { + // Check if temp user has expired + const expirationResponse = await checkTempUserExpiration(req, res); + if (expirationResponse) { + return; // Response already sent by checkTempUserExpiration + } + + // Set up req.user object for controllers that expect it + req.user = { + id: req.session.userId, + email: req.session.userEmail, + isAdmin: req.session.isAdmin || false, + userType: req.session.userType || 'user', + name: req.session.userName || req.session.user_name || null + }; + } + + // Continue regardless of authentication status + next(); +}; + module.exports = { requireAuth, requireAdmin, - requireNonTemp + requireNonTemp, + optionalAuth }; \ No newline at end of file diff --git a/influence/app/middleware/upload.js b/influence/app/middleware/upload.js new file mode 100644 index 0000000..79caec0 --- /dev/null +++ b/influence/app/middleware/upload.js @@ -0,0 +1,47 @@ +const multer = require('multer'); +const path = require('path'); +const fs = require('fs'); + +// Ensure uploads directory exists +const uploadDir = path.join(__dirname, '../public/uploads/responses'); +if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }); +} + +// Configure storage +const storage = multer.diskStorage({ + destination: (req, file, cb) => { + cb(null, uploadDir); + }, + filename: (req, file, cb) => { + // Generate unique filename: timestamp-random-originalname + const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); + const ext = path.extname(file.originalname); + const basename = path.basename(file.originalname, ext); + cb(null, `response-${uniqueSuffix}${ext}`); + } +}); + +// File filter - only images +const fileFilter = (req, file, cb) => { + const allowedTypes = /jpeg|jpg|png|gif|webp/; + const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase()); + const mimetype = allowedTypes.test(file.mimetype); + + if (mimetype && extname) { + return cb(null, true); + } else { + cb(new Error('Only image files (JPEG, PNG, GIF, WebP) are allowed')); + } +}; + +// Configure multer +const upload = multer({ + storage: storage, + limits: { + fileSize: 5 * 1024 * 1024 // 5MB max file size + }, + fileFilter: fileFilter +}); + +module.exports = upload; diff --git a/influence/app/public/admin.html b/influence/app/public/admin.html index df58ebe..c23ab7b 100644 --- a/influence/app/public/admin.html +++ b/influence/app/public/admin.html @@ -726,6 +726,7 @@ + @@ -991,6 +992,28 @@ Sincerely, + +
+
+

Response Moderation

+ +
+ + + +
+ +
+
+
diff --git a/influence/app/public/css/response-wall.css b/influence/app/public/css/response-wall.css new file mode 100644 index 0000000..fbae3f7 --- /dev/null +++ b/influence/app/public/css/response-wall.css @@ -0,0 +1,359 @@ +/* Response Wall Styles */ + +.stats-banner { + display: flex; + justify-content: space-around; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + padding: 2rem; + border-radius: 8px; + margin-bottom: 2rem; +} + +.stat-item { + text-align: center; +} + +.stat-number { + display: block; + font-size: 2.5rem; + font-weight: bold; + margin-bottom: 0.5rem; +} + +.stat-label { + display: block; + font-size: 0.9rem; + opacity: 0.9; +} + +.response-controls { + display: flex; + justify-content: space-between; + margin-bottom: 2rem; + flex-wrap: wrap; + gap: 1rem; + align-items: center; +} + +.filter-group { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.filter-group label { + font-weight: 600; + margin-bottom: 0; +} + +.filter-group select { + padding: 0.5rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1rem; +} + +#submit-response-btn { + margin-left: auto; +} + +/* Response Card */ +.response-card { + background: white; + border: 1px solid #e1e8ed; + border-radius: 8px; + padding: 1.5rem; + margin-bottom: 1.5rem; + transition: box-shadow 0.3s ease; +} + +.response-card:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.response-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: 1rem; +} + +.response-rep-info { + flex: 1; +} + +.response-rep-info h3 { + margin: 0 0 0.25rem 0; + color: #1a202c; + font-size: 1.2rem; +} + +.response-rep-info .rep-meta { + color: #7f8c8d; + font-size: 0.9rem; +} + +.response-rep-info .rep-meta span { + margin-right: 1rem; +} + +.response-badges { + display: flex; + gap: 0.5rem; +} + +.badge { + padding: 0.25rem 0.75rem; + border-radius: 12px; + font-size: 0.85rem; + font-weight: 600; +} + +.badge-verified { + background: #27ae60; + color: white; +} + +.badge-level { + background: #3498db; + color: white; +} + +.badge-type { + background: #95a5a6; + color: white; +} + +.response-content { + margin-bottom: 1rem; +} + +.response-text { + background: #f8f9fa; + padding: 1rem; + border-left: 4px solid #3498db; + border-radius: 4px; + margin-bottom: 1rem; + white-space: pre-wrap; + line-height: 1.6; +} + +.user-comment { + padding: 0.75rem; + background: #fff3cd; + border-left: 4px solid #ffc107; + border-radius: 4px; + margin-bottom: 1rem; +} + +.user-comment-label { + font-weight: 600; + color: #856404; + margin-bottom: 0.5rem; + display: block; +} + +.response-screenshot { + margin-bottom: 1rem; +} + +.response-screenshot img { + max-width: 100%; + border-radius: 4px; + border: 1px solid #ddd; + cursor: pointer; + transition: opacity 0.3s ease; +} + +.response-screenshot img:hover { + opacity: 0.8; +} + +.response-footer { + display: flex; + justify-content: space-between; + align-items: center; + padding-top: 1rem; + border-top: 1px solid #e1e8ed; +} + +.response-meta { + color: #7f8c8d; + font-size: 0.9rem; +} + +.response-actions { + display: flex; + gap: 1rem; +} + +.upvote-btn { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + border: 2px solid #3498db; + background: white; + color: #3498db; + border-radius: 20px; + cursor: pointer; + transition: all 0.3s ease; + font-weight: 600; +} + +.upvote-btn:hover { + background: #3498db; + color: white; + transform: translateY(-2px); +} + +.upvote-btn.upvoted { + background: #3498db; + color: white; +} + +.upvote-btn .upvote-icon { + font-size: 1.2rem; +} + +.upvote-count { + font-weight: bold; +} + +/* Empty State */ +.empty-state { + text-align: center; + padding: 3rem; + color: #7f8c8d; +} + +.empty-state p { + font-size: 1.2rem; + margin-bottom: 1.5rem; +} + +/* Modal Styles */ +.modal { + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + overflow: auto; + background-color: rgba(0, 0, 0, 0.5); + display: none; +} + +.modal-content { + background-color: white; + margin: 5% auto; + padding: 2rem; + border-radius: 8px; + max-width: 600px; + position: relative; +} + +.close { + color: #aaa; + float: right; + font-size: 28px; + font-weight: bold; + cursor: pointer; +} + +.close:hover, +.close:focus { + color: #000; +} + +.form-group { + margin-bottom: 1.5rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: 600; + color: #1a202c; +} + +.form-group input, +.form-group select, +.form-group textarea { + width: 100%; + padding: 0.75rem; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 1rem; +} + +.form-group small { + display: block; + margin-top: 0.25rem; + color: #7f8c8d; +} + +.form-actions { + display: flex; + gap: 1rem; + margin-top: 1.5rem; +} + +.form-actions .btn { + flex: 1; +} + +.loading { + text-align: center; + padding: 2rem; + color: #7f8c8d; +} + +.load-more-container { + text-align: center; + margin: 2rem 0; +} + +/* Responsive Design */ +@media (max-width: 768px) { + .stats-banner { + flex-direction: column; + gap: 1.5rem; + } + + .response-controls { + flex-direction: column; + align-items: stretch; + } + + .filter-group { + flex-direction: column; + align-items: stretch; + } + + #submit-response-btn { + margin-left: 0; + width: 100%; + } + + .response-header { + flex-direction: column; + } + + .response-badges { + margin-top: 1rem; + } + + .response-footer { + flex-direction: column; + gap: 1rem; + align-items: flex-start; + } + + .modal-content { + margin: 10% 5%; + padding: 1rem; + } +} diff --git a/influence/app/public/css/styles.css b/influence/app/public/css/styles.css index c231c81..e7ffa27 100644 --- a/influence/app/public/css/styles.css +++ b/influence/app/public/css/styles.css @@ -257,6 +257,10 @@ header p { border-radius: 8px; padding: 20px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + /* Prevent width changes when inline composer is added */ + position: relative; + width: 100%; + box-sizing: border-box; } .rep-category h3 { @@ -358,6 +362,169 @@ header p { border-color: #1e7e34; } +/* Inline Email Composer Styles */ +.inline-email-composer { + margin-top: 20px; + padding: 20px; + background: white; + border: 2px solid #005a9c; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + overflow: hidden; + max-height: 0; + opacity: 0; + transition: max-height 0.4s ease, opacity 0.3s ease, margin-top 0.4s ease, padding 0.4s ease; + /* Ensure it's not affected by any parent grid/flex container */ + width: 100% !important; + max-width: none !important; + min-width: 0 !important; + grid-column: 1 / -1; /* Span full width if inside a grid */ + display: block !important; + box-sizing: border-box !important; +} + +.inline-email-composer.active { + max-height: 2000px; + opacity: 1; + margin-top: 20px; + padding: 20px; +} + +.inline-email-composer.closing { + max-height: 0; + opacity: 0; + margin-top: 0; + padding: 0 20px; +} + +.inline-email-composer .composer-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + padding-bottom: 15px; + border-bottom: 2px solid #e9ecef; +} + +.inline-email-composer .composer-header h3 { + color: #005a9c; + margin: 0; + font-size: 1.3em; +} + +.inline-email-composer .close-btn { + font-size: 24px; + font-weight: bold; + cursor: pointer; + color: #999; + transition: color 0.3s ease; + background: none; + border: none; + padding: 0; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; +} + +.inline-email-composer .close-btn:hover { + color: #333; +} + +.inline-email-composer .composer-body { + animation: slideIn 0.3s ease; +} + +@keyframes slideIn { + from { + transform: translateY(-20px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +.inline-email-composer .email-preview-details { + background-color: #f8f9fa; + padding: 15px; + border-radius: 6px; + border: 1px solid #e9ecef; + margin-bottom: 20px; +} + +.inline-email-composer .detail-row { + margin-bottom: 8px; + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.inline-email-composer .detail-row:last-child { + margin-bottom: 0; +} + +.inline-email-composer .detail-row strong { + color: #495057; + min-width: 90px; + flex-shrink: 0; +} + +.inline-email-composer .detail-row span { + color: #333; + word-break: break-word; +} + +.inline-email-composer .email-preview-content { + background-color: white; + border: 1px solid #ddd; + border-radius: 6px; + padding: 20px; + max-height: 400px; + overflow-y: auto; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + line-height: 1.6; +} + +/* Wrapper for sanitized email HTML to prevent it affecting parent page */ +.inline-email-composer .email-preview-body { + max-width: 600px; + margin: 0 auto; + width: 100%; + box-sizing: border-box; +} + +.inline-email-composer .email-preview-content p { + margin-bottom: 12px; +} + +.inline-email-composer .email-preview-content p:last-child { + margin-bottom: 0; +} + +/* Ensure all inline composer child elements respect full width */ +.inline-email-composer .composer-header, +.inline-email-composer .composer-body, +.inline-email-composer .recipient-info, +.inline-email-composer .inline-email-form, +.inline-email-composer .form-group, +.inline-email-composer .form-actions, +.inline-email-composer .email-preview-details, +.inline-email-composer .email-preview-content { + width: 100%; + max-width: none; + box-sizing: border-box; +} + +.inline-email-composer .form-group input, +.inline-email-composer .form-group textarea { + width: 100%; + max-width: none; + box-sizing: border-box; +} + /* Modal Styles */ .modal { position: fixed; diff --git a/influence/app/public/js/admin.js b/influence/app/public/js/admin.js index 04c1b68..e56b3ce 100644 --- a/influence/app/public/js/admin.js +++ b/influence/app/public/js/admin.js @@ -109,6 +109,14 @@ class AdminPanel { this.handleUserTypeChange(e.target.value); }); } + + // Response status filter + const responseStatusSelect = document.getElementById('admin-response-status'); + if (responseStatusSelect) { + responseStatusSelect.addEventListener('change', () => { + this.loadAdminResponses(); + }); + } } setupFormInteractions() { @@ -407,6 +415,8 @@ class AdminPanel { if (this.currentCampaign) { this.populateEditForm(); } + } else if (tabName === 'responses') { + this.loadAdminResponses(); } else if (tabName === 'users') { this.loadUsers(); } @@ -1051,6 +1061,231 @@ class AdminPanel { this.showMessage('Failed to send emails: ' + error.message, 'error'); } } + + // Response Moderation Functions + async loadAdminResponses() { + const status = document.getElementById('admin-response-status').value; + const container = document.getElementById('admin-responses-container'); + const loading = document.getElementById('responses-loading'); + + loading.classList.remove('hidden'); + container.innerHTML = ''; + + try { + const params = new URLSearchParams({ status, limit: 100 }); + const response = await window.apiClient.get(`/admin/responses?${params}`); + + loading.classList.add('hidden'); + + if (response.success && response.responses.length > 0) { + this.renderAdminResponses(response.responses); + } else { + container.innerHTML = '

No responses found.

'; + } + } catch (error) { + loading.classList.add('hidden'); + console.error('Error loading responses:', error); + this.showMessage('Failed to load responses', 'error'); + } + } + + renderAdminResponses(responses) { + const container = document.getElementById('admin-responses-container'); + + console.log('Rendering admin responses:', responses.length, 'responses'); + if (responses.length > 0) { + console.log('First response sample:', responses[0]); + } + + container.innerHTML = responses.map(response => { + const createdDate = new Date(response.created_at).toLocaleString(); + const statusClass = { + 'pending': 'warning', + 'approved': 'success', + 'rejected': 'danger' + }[response.status] || 'secondary'; + + return ` +
+
+
+

${this.escapeHtml(response.representative_name)}

+
+ ${this.escapeHtml(response.representative_level)} • + ${this.escapeHtml(response.response_type)} • + ${createdDate} +
+
+
+ + ${response.status.toUpperCase()} + + ${response.is_verified ? '✓ VERIFIED' : ''} +
+
+ +
+ Response: +

${this.escapeHtml(response.response_text)}

+
+ + ${response.user_comment ? ` +
+ User Comment: +

${this.escapeHtml(response.user_comment)}

+
+ ` : ''} + + ${response.screenshot_url ? ` +
+ Screenshot +
+ ` : ''} + +
+ Submitted by: ${response.is_anonymous ? 'Anonymous' : (this.escapeHtml(response.submitted_by_name) || this.escapeHtml(response.submitted_by_email) || 'Unknown')} • + Campaign: ${this.escapeHtml(response.campaign_slug)} • + Upvotes: ${response.upvote_count || 0} +
+ +
+ ${response.status === 'pending' ? ` + + + ` : ''} + ${response.status === 'approved' && !response.is_verified ? ` + + ` : ''} + ${response.status === 'approved' && response.is_verified ? ` + + ` : ''} + ${response.status === 'rejected' ? ` + + ` : ''} + ${response.status === 'approved' ? ` + + ` : ''} + +
+
+ `; + }).join(''); + + // Add event delegation for response actions + this.setupResponseActionListeners(); + } + + async approveResponse(id) { + await this.updateResponseStatus(id, 'approved'); + } + + async rejectResponse(id) { + await this.updateResponseStatus(id, 'rejected'); + } + + async updateResponseStatus(id, status) { + try { + const response = await window.apiClient.patch(`/admin/responses/${id}/status`, { status }); + + if (response.success) { + this.showMessage(`Response ${status} successfully!`, 'success'); + this.loadAdminResponses(); + } else { + throw new Error(response.error || 'Failed to update response status'); + } + } catch (error) { + console.error('Error updating response status:', error); + this.showMessage('Failed to update response status: ' + error.message, 'error'); + } + } + + async toggleVerified(id, isVerified) { + try { + const response = await window.apiClient.patch(`/admin/responses/${id}`, { + is_verified: isVerified + }); + + if (response.success) { + this.showMessage(isVerified ? 'Response marked as verified!' : 'Verification removed!', 'success'); + this.loadAdminResponses(); + } else { + throw new Error(response.error || 'Failed to update verification status'); + } + } catch (error) { + console.error('Error updating verification:', error); + this.showMessage('Failed to update verification: ' + error.message, 'error'); + } + } + + async deleteResponse(id) { + if (!confirm('Are you sure you want to delete this response? This action cannot be undone.')) return; + + try { + const response = await window.apiClient.delete(`/admin/responses/${id}`); + + if (response.success) { + this.showMessage('Response deleted successfully!', 'success'); + this.loadAdminResponses(); + } else { + throw new Error(response.error || 'Failed to delete response'); + } + } catch (error) { + console.error('Error deleting response:', error); + this.showMessage('Failed to delete response: ' + error.message, 'error'); + } + } + + setupResponseActionListeners() { + const container = document.getElementById('admin-responses-container'); + if (!container) return; + + // Remove old listener if exists to avoid duplicates + const oldListener = container._responseActionListener; + if (oldListener) { + container.removeEventListener('click', oldListener); + } + + // Create new listener + const listener = (e) => { + const target = e.target; + const action = target.dataset.action; + const responseId = target.dataset.responseId; + + console.log('Response action clicked:', { action, responseId, target }); + + if (!action || !responseId) { + console.log('Missing action or responseId, ignoring click'); + return; + } + + switch (action) { + case 'approve-response': + this.approveResponse(parseInt(responseId)); + break; + case 'reject-response': + this.rejectResponse(parseInt(responseId)); + break; + case 'verify-response': + const isVerified = target.dataset.verified === 'true'; + this.toggleVerified(parseInt(responseId), isVerified); + break; + case 'delete-response': + this.deleteResponse(parseInt(responseId)); + break; + } + }; + + // Store listener reference and add it + container._responseActionListener = listener; + container.addEventListener('click', listener); + } + + escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } } // Initialize admin panel when DOM is loaded diff --git a/influence/app/public/js/api-client.js b/influence/app/public/js/api-client.js index 24a1358..23f709a 100644 --- a/influence/app/public/js/api-client.js +++ b/influence/app/public/js/api-client.js @@ -45,6 +45,26 @@ class APIClient { }); } + async put(endpoint, data) { + return this.makeRequest(endpoint, { + method: 'PUT', + body: JSON.stringify(data) + }); + } + + async patch(endpoint, data) { + return this.makeRequest(endpoint, { + method: 'PATCH', + body: JSON.stringify(data) + }); + } + + async delete(endpoint) { + return this.makeRequest(endpoint, { + method: 'DELETE' + }); + } + async postFormData(endpoint, formData) { // Don't set Content-Type header - browser will set it with boundary for multipart/form-data const config = { diff --git a/influence/app/public/js/email-composer.js b/influence/app/public/js/email-composer.js index 0940bac..eef834f 100644 --- a/influence/app/public/js/email-composer.js +++ b/influence/app/public/js/email-composer.js @@ -16,6 +16,8 @@ class EmailComposer { this.currentRecipient = null; this.currentEmailData = null; this.lastPreviewTime = 0; // Track last preview request time + this.currentInlineComposer = null; // Track currently open inline composer + this.currentRepCard = null; // Track which rep card has the composer open this.init(); } @@ -67,7 +69,533 @@ class EmailComposer { }); } - openModal(recipient) { + openModal(recipient, repCard = null) { + // Close any existing inline composers first + this.closeInlineComposer(); + + this.currentRecipient = recipient; + this.currentRepCard = repCard; + + // If repCard is provided, create inline composer + if (repCard) { + this.createInlineComposer(repCard, recipient); + return; + } + + // Otherwise, use modal (fallback) + this.openModalDialog(recipient); + } + + createInlineComposer(repCard, recipient) { + // Explicitly hide old modals + if (this.modal) this.modal.style.display = 'none'; + if (this.previewModal) this.previewModal.style.display = 'none'; + + // Create the inline composer HTML + const composerHTML = this.getComposerHTML(recipient); + + // Create a container div + const composerDiv = document.createElement('div'); + composerDiv.className = 'inline-email-composer'; + composerDiv.innerHTML = composerHTML; + + // Find the parent category container (not the grid) + // The structure is: rep-category > rep-cards (grid) > rep-card + const repCardsGrid = repCard.parentElement; // The grid container + const repCategory = repCardsGrid.parentElement; // The category container + + // Insert after the grid, not inside it + repCategory.insertAdjacentElement('beforeend', composerDiv); + this.currentInlineComposer = composerDiv; + + // Trigger animation after a small delay + setTimeout(() => { + composerDiv.classList.add('active'); + }, 10); + + // Attach event listeners to the inline form + this.attachInlineFormListeners(composerDiv); + + // Focus on first input + setTimeout(() => { + const firstInput = composerDiv.querySelector('#inline-sender-name'); + if (firstInput) firstInput.focus(); + }, 400); + + // Scroll the composer into view + setTimeout(() => { + composerDiv.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }, 100); + } + + getComposerHTML(recipient) { + const postalCode = window.postalLookup ? window.postalLookup.currentPostalCode : ''; + const defaultSubject = `Message from your constituent from ${postalCode}`; + + return ` +
+

Compose Email to ${recipient.name}

+ +
+
+
+ ${recipient.name}
+ ${recipient.office}
+ ${recipient.district}
+ ${recipient.email} +
+ +
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + 5000 characters remaining +
+ +
+ + +
+
+
+ `; + } + + attachInlineFormListeners(composerDiv) { + // Close button + const closeBtn = composerDiv.querySelector('.inline-close'); + closeBtn.addEventListener('click', () => this.closeInlineComposer()); + + // Cancel button + const cancelBtn = composerDiv.querySelector('.inline-cancel'); + cancelBtn.addEventListener('click', () => this.closeInlineComposer()); + + // Form submission + const form = composerDiv.querySelector('.inline-email-form'); + form.addEventListener('submit', (e) => this.handleInlinePreview(e, composerDiv)); + + // Character counter + const messageTextarea = composerDiv.querySelector('#inline-email-message'); + const charCounter = composerDiv.querySelector('.inline-char-counter'); + messageTextarea.addEventListener('input', () => { + const maxLength = 5000; + const currentLength = messageTextarea.value.length; + const remaining = maxLength - currentLength; + charCounter.textContent = `${remaining} characters remaining`; + if (remaining < 100) { + charCounter.style.color = '#d9534f'; + } else { + charCounter.style.color = '#666'; + } + }); + + // Update subject with sender name + const senderNameField = composerDiv.querySelector('#inline-sender-name'); + const subjectField = composerDiv.querySelector('#inline-email-subject'); + const postalCodeField = composerDiv.querySelector('#inline-sender-postal-code'); + + senderNameField.addEventListener('input', () => { + const senderName = senderNameField.value.trim(); + const postalCode = postalCodeField.value.trim(); + if (senderName && postalCode) { + subjectField.value = `Message from ${senderName} - ${postalCode}`; + } + }); + } + + closeInlineComposer() { + if (this.currentInlineComposer) { + // Add closing class for animation + this.currentInlineComposer.classList.remove('active'); + this.currentInlineComposer.classList.add('closing'); + + // Remove from DOM after animation + setTimeout(() => { + if (this.currentInlineComposer && this.currentInlineComposer.parentNode) { + this.currentInlineComposer.parentNode.removeChild(this.currentInlineComposer); + } + this.currentInlineComposer = null; + this.currentRepCard = null; + }, 400); + } + } + + async handleInlinePreview(e, composerDiv) { + e.preventDefault(); + + // Prevent duplicate calls + const currentTime = Date.now(); + if (currentTime - this.lastPreviewTime < 2000) { + return; + } + this.lastPreviewTime = currentTime; + + // Get form data from inline form + const formData = this.getInlineFormData(composerDiv); + const errors = this.validateInlineForm(formData); + + if (errors.length > 0) { + window.messageDisplay.show(errors.join('
'), 'error'); + return; + } + + const submitButton = composerDiv.querySelector('button[type="submit"]'); + const originalText = submitButton.textContent; + + try { + submitButton.disabled = true; + submitButton.textContent = 'Loading preview...'; + + // Store form data for later use - match API expected field names + this.currentEmailData = { + recipientEmail: formData.recipientEmail, + senderName: formData.senderName, + senderEmail: formData.senderEmail, + postalCode: formData.postalCode, // API expects 'postalCode' not 'senderPostalCode' + subject: formData.subject, + message: formData.message + }; + + // Get preview from backend + const response = await window.apiClient.previewEmail(this.currentEmailData); + + if (response.success) { + // Replace inline composer with inline preview + this.showInlinePreview(response.preview, composerDiv); + } else { + window.messageDisplay.show(response.error || 'Failed to generate preview', 'error'); + } + } catch (error) { + console.error('Preview error:', error); + window.messageDisplay.show('Failed to generate email preview. Please try again.', 'error'); + } finally { + submitButton.disabled = false; + submitButton.textContent = originalText; + } + } + + getInlineFormData(composerDiv) { + return { + recipientEmail: composerDiv.querySelector('#inline-recipient-email').value.trim(), + senderName: composerDiv.querySelector('#inline-sender-name').value.trim(), + senderEmail: composerDiv.querySelector('#inline-sender-email').value.trim(), + postalCode: composerDiv.querySelector('#inline-sender-postal-code').value.trim(), + subject: composerDiv.querySelector('#inline-email-subject').value.trim(), + message: composerDiv.querySelector('#inline-email-message').value.trim() + }; + } + + validateInlineForm(formData) { + const errors = []; + + if (!formData.senderName) { + errors.push('Please enter your name'); + } + + if (!formData.senderEmail) { + errors.push('Please enter your email'); + } else if (!this.validateEmail(formData.senderEmail)) { + errors.push('Please enter a valid email address'); + } + + if (!formData.subject) { + errors.push('Please enter a subject'); + } + + if (!formData.message) { + errors.push('Please enter a message'); + } else if (formData.message.length < 10) { + errors.push('Message must be at least 10 characters long'); + } + + // Validate postal code format if provided (required by API) + if (!formData.postalCode || formData.postalCode.trim() === '') { + errors.push('Postal code is required'); + } else { + // Check postal code format: A1A 1A1 or A1A1A1 + const postalCodePattern = /^[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d$/; + if (!postalCodePattern.test(formData.postalCode)) { + errors.push('Please enter a valid Canadian postal code (e.g., T5N 4B8)'); + } + } + + // Check for suspicious content + if (this.containsSuspiciousContent(formData.message) || this.containsSuspiciousContent(formData.subject)) { + errors.push('Message contains potentially malicious content'); + } + + return errors; + } + + showInlinePreview(preview, composerDiv) { + // Ensure old preview modal is hidden + if (this.previewModal) { + this.previewModal.style.display = 'none'; + } + + // Replace the composer content with preview content + const previewHTML = this.getPreviewHTML(preview); + + // Fade out composer body + const composerBody = composerDiv.querySelector('.composer-body'); + composerBody.style.opacity = '0'; + composerBody.style.transition = 'opacity 0.2s ease'; + + setTimeout(() => { + composerBody.innerHTML = previewHTML; + composerBody.style.opacity = '1'; + + // Attach event listeners to preview buttons + this.attachPreviewListeners(composerDiv); + + // Update header + const header = composerDiv.querySelector('.composer-header h3'); + header.textContent = 'Email Preview'; + + // Scroll to make sure preview is visible + setTimeout(() => { + composerDiv.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + }, 100); + }, 200); + } + + getPreviewHTML(preview) { + const html = preview.html || ''; + const testModeWarning = preview.testMode ? + `
+ TEST MODE: Email will be redirected to ${this.escapeHtml(preview.redirectTo)} +
` : ''; + + // Sanitize the HTML to remove any `; + }); + + // Wrap the sanitized content in a container div + return ``; + } + + attachPreviewListeners(composerDiv) { + // Edit button - go back to composer + const editBtn = composerDiv.querySelector('.preview-edit'); + editBtn.addEventListener('click', () => { + this.returnToComposer(composerDiv); + }); + + // Send button - send the email + const sendBtn = composerDiv.querySelector('.preview-send'); + sendBtn.addEventListener('click', async () => { + await this.sendFromInlinePreview(sendBtn, composerDiv); + }); + } + + returnToComposer(composerDiv) { + // Restore the composer form with the current data + const composerFormHTML = this.getComposerFormHTML(); + + const composerBody = composerDiv.querySelector('.composer-body'); + composerBody.style.opacity = '0'; + + setTimeout(() => { + composerBody.innerHTML = composerFormHTML; + + // Re-populate form fields with saved data + if (this.currentEmailData) { + composerDiv.querySelector('#inline-sender-name').value = this.currentEmailData.senderName || ''; + composerDiv.querySelector('#inline-sender-email').value = this.currentEmailData.senderEmail || ''; + composerDiv.querySelector('#inline-sender-postal-code').value = this.currentEmailData.postalCode || ''; + composerDiv.querySelector('#inline-email-subject').value = this.currentEmailData.subject || ''; + composerDiv.querySelector('#inline-email-message').value = this.currentEmailData.message || ''; + + // Update character counter + const messageField = composerDiv.querySelector('#inline-email-message'); + const charCounter = composerDiv.querySelector('.inline-char-counter'); + const remaining = 5000 - messageField.value.length; + charCounter.textContent = `${remaining} characters remaining`; + } + + composerBody.style.opacity = '1'; + + // Re-attach form listeners + this.attachInlineFormListeners(composerDiv); + + // Update header + const header = composerDiv.querySelector('.composer-header h3'); + header.textContent = `Compose Email to ${this.currentRecipient.name}`; + }, 200); + } + + getComposerFormHTML() { + const recipient = this.currentRecipient; + return ` +
+ ${recipient.name}
+ ${recipient.office}
+ ${recipient.district}
+ ${recipient.email} +
+ +
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + 5000 characters remaining +
+ +
+ + +
+
+ `; + } + + async sendFromInlinePreview(sendBtn, composerDiv) { + if (!this.currentEmailData) { + window.messageDisplay.show('No email data to send', 'error'); + return; + } + + const originalText = sendBtn.textContent; + + try { + sendBtn.disabled = true; + sendBtn.textContent = 'Sending...'; + + const result = await window.apiClient.sendEmail(this.currentEmailData); + + if (result.success) { + window.messageDisplay.show('Email sent successfully! Your representative will receive your message.', 'success', 7000); + + // Close the inline composer after successful send + this.closeInlineComposer(); + this.currentEmailData = null; + this.currentRecipient = null; + } else { + throw new Error(result.message || 'Failed to send email'); + } + + } catch (error) { + console.error('Email send failed:', error); + + // Handle rate limit errors specifically + if (error.message && error.message.includes('rate limit')) { + window.messageDisplay.show('You are sending emails too quickly. Please wait a few minutes and try again.', 'error', 8000); + } else { + window.messageDisplay.show(`Failed to send email: ${error.message}`, 'error', 6000); + } + } finally { + sendBtn.disabled = false; + sendBtn.textContent = originalText; + } + } + + openModalDialog(recipient) { this.currentRecipient = recipient; // Populate recipient info diff --git a/influence/app/public/js/representatives-display.js b/influence/app/public/js/representatives-display.js index 383f8fc..4f9894e 100644 --- a/influence/app/public/js/representatives-display.js +++ b/influence/app/public/js/representatives-display.js @@ -354,43 +354,49 @@ class RepresentativesDisplay { } attachEventListeners() { - // Add event listeners for compose email buttons + // Email compose buttons const composeButtons = this.container.querySelectorAll('.compose-email'); composeButtons.forEach(button => { button.addEventListener('click', (e) => { - const email = e.target.dataset.email; - const name = e.target.dataset.name; - const office = e.target.dataset.office; - const district = e.target.dataset.district; + e.preventDefault(); + const email = button.dataset.email; + const name = button.dataset.name; + const office = button.dataset.office; + const district = button.dataset.district; - window.emailComposer.openModal({ - email, - name, - office, - district - }); + // Find the closest rep-card ancestor + const repCard = button.closest('.rep-card'); + + if (window.emailComposer) { + window.emailComposer.openModal({ + email, + name, + office, + district + }, repCard); // Pass the card element + } }); }); - - // Add event listeners for call buttons + + // Call buttons const callButtons = this.container.querySelectorAll('.call-representative'); callButtons.forEach(button => { button.addEventListener('click', (e) => { - const phone = e.target.dataset.phone; - const name = e.target.dataset.name; - const office = e.target.dataset.office; - const officeType = e.target.dataset.officeType; + e.preventDefault(); + const phone = button.dataset.phone; + const name = button.dataset.name; + const office = button.dataset.office; + const officeType = button.dataset.officeType || ''; this.handleCallClick(phone, name, office, officeType); }); }); - // Add event listeners for visit buttons + // Visit buttons (for office addresses) const visitButtons = this.container.querySelectorAll('.visit-office'); visitButtons.forEach(button => { button.addEventListener('click', (e) => { e.preventDefault(); - // Use currentTarget to ensure we get the button, not nested elements const address = button.dataset.address; const name = button.dataset.name; const office = button.dataset.office; @@ -398,15 +404,14 @@ class RepresentativesDisplay { this.handleVisitClick(address, name, office); }); }); - - // Add event listeners for image error handling - const repImages = this.container.querySelectorAll('.rep-photo img'); - repImages.forEach(img => { - img.addEventListener('error', (e) => { - // Hide the image and show the fallback - e.target.style.display = 'none'; - const fallback = e.target.nextElementSibling; - if (fallback && fallback.classList.contains('rep-photo-fallback')) { + + // Photo error handling (fallback to initials) + const photos = this.container.querySelectorAll('.rep-photo img'); + photos.forEach(img => { + img.addEventListener('error', function() { + this.style.display = 'none'; + const fallback = this.parentElement.querySelector('.rep-photo-fallback'); + if (fallback) { fallback.style.display = 'flex'; } }); diff --git a/influence/app/public/js/response-wall.js b/influence/app/public/js/response-wall.js new file mode 100644 index 0000000..9a60d7b --- /dev/null +++ b/influence/app/public/js/response-wall.js @@ -0,0 +1,365 @@ +// Response Wall JavaScript + +let currentCampaignSlug = null; +let currentOffset = 0; +let currentSort = 'recent'; +let currentLevel = ''; +const LIMIT = 20; + +// Initialize +document.addEventListener('DOMContentLoaded', () => { + console.log('Response Wall: Initializing...'); + + // Get campaign slug from URL if present + const urlParams = new URLSearchParams(window.location.search); + currentCampaignSlug = urlParams.get('campaign'); + + console.log('Campaign slug:', currentCampaignSlug); + + if (!currentCampaignSlug) { + showError('No campaign specified'); + return; + } + + // Load initial data + loadResponseStats(); + loadResponses(true); + + // Set up event listeners + document.getElementById('sort-select').addEventListener('change', (e) => { + currentSort = e.target.value; + loadResponses(true); + }); + + document.getElementById('level-filter').addEventListener('change', (e) => { + currentLevel = e.target.value; + loadResponses(true); + }); + + const submitBtn = document.getElementById('submit-response-btn'); + console.log('Submit button found:', !!submitBtn); + if (submitBtn) { + submitBtn.addEventListener('click', () => { + console.log('Submit button clicked'); + openSubmitModal(); + }); + } + + // Use event delegation for empty state button since it's dynamically shown + document.addEventListener('click', (e) => { + if (e.target.id === 'empty-state-submit-btn') { + console.log('Empty state button clicked'); + openSubmitModal(); + } + }); + + const modalCloseBtn = document.getElementById('modal-close-btn'); + if (modalCloseBtn) { + modalCloseBtn.addEventListener('click', closeSubmitModal); + } + + const cancelBtn = document.getElementById('cancel-submit-btn'); + if (cancelBtn) { + cancelBtn.addEventListener('click', closeSubmitModal); + } + + const loadMoreBtn = document.getElementById('load-more-btn'); + if (loadMoreBtn) { + loadMoreBtn.addEventListener('click', loadMoreResponses); + } + + const form = document.getElementById('submit-response-form'); + if (form) { + form.addEventListener('submit', handleSubmitResponse); + } + + console.log('Response Wall: Initialization complete'); +}); + +// Load response statistics +async function loadResponseStats() { + try { + const response = await fetch(`/api/campaigns/${currentCampaignSlug}/response-stats`); + const data = await response.json(); + + if (data.success) { + document.getElementById('stat-total-responses').textContent = data.stats.totalResponses; + document.getElementById('stat-verified').textContent = data.stats.verifiedResponses; + document.getElementById('stat-upvotes').textContent = data.stats.totalUpvotes; + document.getElementById('stats-banner').style.display = 'flex'; + } + } catch (error) { + console.error('Error loading stats:', error); + } +} + +// Load responses +async function loadResponses(reset = false) { + if (reset) { + currentOffset = 0; + document.getElementById('responses-container').innerHTML = ''; + } + + showLoading(true); + + try { + const params = new URLSearchParams({ + sort: currentSort, + level: currentLevel, + offset: currentOffset, + limit: LIMIT + }); + + const response = await fetch(`/api/campaigns/${currentCampaignSlug}/responses?${params}`); + const data = await response.json(); + + showLoading(false); + + if (data.success && data.responses.length > 0) { + renderResponses(data.responses); + + // Show/hide load more button + if (data.pagination.hasMore) { + document.getElementById('load-more-container').style.display = 'block'; + } else { + document.getElementById('load-more-container').style.display = 'none'; + } + } else if (reset) { + showEmptyState(); + } + } catch (error) { + showLoading(false); + showError('Failed to load responses'); + console.error('Error loading responses:', error); + } +} + +// Render responses +function renderResponses(responses) { + const container = document.getElementById('responses-container'); + + responses.forEach(response => { + const card = createResponseCard(response); + container.appendChild(card); + }); +} + +// Create response card element +function createResponseCard(response) { + const card = document.createElement('div'); + card.className = 'response-card'; + card.dataset.responseId = response.id; + + const createdDate = new Date(response.created_at).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric' + }); + + let badges = `${escapeHtml(response.representative_level)}`; + badges += `${escapeHtml(response.response_type)}`; + if (response.is_verified) { + badges = `✓ Verified` + badges; + } + + let submittedBy = 'Anonymous'; + if (!response.is_anonymous && response.submitted_by_name) { + submittedBy = escapeHtml(response.submitted_by_name); + } + + let userCommentHtml = ''; + if (response.user_comment) { + userCommentHtml = ` +
+ Constituent's Comment: + ${escapeHtml(response.user_comment)} +
+ `; + } + + let screenshotHtml = ''; + if (response.screenshot_url) { + screenshotHtml = ` +
+ Response screenshot +
+ `; + } + + const upvoteClass = response.hasUpvoted ? 'upvoted' : ''; + + card.innerHTML = ` +
+
+

${escapeHtml(response.representative_name)}

+
+ ${response.representative_title ? `${escapeHtml(response.representative_title)}` : ''} + ${createdDate} +
+
+
+ ${badges} +
+
+ +
+
${escapeHtml(response.response_text)}
+ ${userCommentHtml} + ${screenshotHtml} +
+ + + `; + + // Add event listener for upvote button + const upvoteBtn = card.querySelector('.upvote-btn'); + upvoteBtn.addEventListener('click', function() { + toggleUpvote(response.id, this); + }); + + // Add event listener for screenshot image if present + const screenshotImg = card.querySelector('.screenshot-image'); + if (screenshotImg) { + screenshotImg.addEventListener('click', function() { + viewImage(this.dataset.imageUrl); + }); + } + + return card; +} + +// Toggle upvote +async function toggleUpvote(responseId, button) { + const isUpvoted = button.classList.contains('upvoted'); + const url = `/api/responses/${responseId}/upvote`; + + try { + const response = await fetch(url, { + method: isUpvoted ? 'DELETE' : 'POST', + headers: { + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + + if (data.success) { + // Update button state + button.classList.toggle('upvoted'); + button.querySelector('.upvote-count').textContent = data.upvoteCount; + + // Reload stats + loadResponseStats(); + } else { + showError(data.error || 'Failed to update upvote'); + } + } catch (error) { + console.error('Error toggling upvote:', error); + showError('Failed to update upvote'); + } +} + +// Load more responses +function loadMoreResponses() { + currentOffset += LIMIT; + loadResponses(false); +} + +// Open submit modal +function openSubmitModal() { + console.log('openSubmitModal called'); + const modal = document.getElementById('submit-modal'); + if (modal) { + modal.style.display = 'block'; + console.log('Modal displayed'); + } else { + console.error('Modal element not found'); + } +} + +// Close submit modal +function closeSubmitModal() { + document.getElementById('submit-modal').style.display = 'none'; + document.getElementById('submit-response-form').reset(); +} + +// Handle response submission +async function handleSubmitResponse(e) { + e.preventDefault(); + + const formData = new FormData(e.target); + + try { + const response = await fetch(`/api/campaigns/${currentCampaignSlug}/responses`, { + method: 'POST', + body: formData + }); + + const data = await response.json(); + + if (data.success) { + showSuccess(data.message || 'Response submitted successfully! It will appear after moderation.'); + closeSubmitModal(); + // Don't reload responses since submission is pending approval + } else { + showError(data.error || 'Failed to submit response'); + } + } catch (error) { + console.error('Error submitting response:', error); + showError('Failed to submit response'); + } +} + +// View image in modal/new tab +function viewImage(url) { + window.open(url, '_blank'); +} + +// Utility functions +function showLoading(show) { + document.getElementById('loading').style.display = show ? 'block' : 'none'; +} + +function showEmptyState() { + document.getElementById('empty-state').style.display = 'block'; + document.getElementById('responses-container').innerHTML = ''; + document.getElementById('load-more-container').style.display = 'none'; +} + +function showError(message) { + // Simple alert for now - could integrate with existing error display system + alert('Error: ' + message); +} + +function showSuccess(message) { + // Simple alert for now - could integrate with existing success display system + alert(message); +} + +function escapeHtml(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// Close modal when clicking outside +window.onclick = function(event) { + const modal = document.getElementById('submit-modal'); + if (event.target === modal) { + closeSubmitModal(); + } +}; diff --git a/influence/app/public/response-wall.html b/influence/app/public/response-wall.html new file mode 100644 index 0000000..005cf64 --- /dev/null +++ b/influence/app/public/response-wall.html @@ -0,0 +1,163 @@ + + + + + + Response Wall | BNKops Influence + + + + +
+
+

📢 Community Response Wall

+

See what representatives are saying back to constituents

+
+ + + + + +
+
+ + +
+ +
+ + +
+ + +
+ + + + + + + + +
+ + + +
+ + + + + + + diff --git a/influence/app/routes/api.js b/influence/app/routes/api.js index 903276a..8ef3c22 100644 --- a/influence/app/routes/api.js +++ b/influence/app/routes/api.js @@ -4,8 +4,10 @@ const { body, param, validationResult } = require('express-validator'); const representativesController = require('../controllers/representatives'); const emailsController = require('../controllers/emails'); const campaignsController = require('../controllers/campaigns'); +const responsesController = require('../controllers/responses'); const rateLimiter = require('../utils/rate-limiter'); -const { requireAdmin, requireAuth, requireNonTemp } = require('../middleware/auth'); +const { requireAdmin, requireAuth, requireNonTemp, optionalAuth } = require('../middleware/auth'); +const upload = require('../middleware/upload'); // Import user routes const userRoutes = require('./users'); @@ -192,4 +194,51 @@ router.post( // User management routes (admin only) router.use('/admin/users', userRoutes); +// Response Wall Routes +router.get('/campaigns/:slug/responses', rateLimiter.general, responsesController.getCampaignResponses); +router.get('/campaigns/:slug/response-stats', rateLimiter.general, responsesController.getResponseStats); +router.post( + '/campaigns/:slug/responses', + optionalAuth, + upload.single('screenshot'), + rateLimiter.general, + [ + body('representative_name').notEmpty().withMessage('Representative name is required'), + body('representative_level').isIn(['Federal', 'Provincial', 'Municipal', 'School Board']).withMessage('Invalid representative level'), + body('response_type').isIn(['Email', 'Letter', 'Phone Call', 'Meeting', 'Social Media', 'Other']).withMessage('Invalid response type'), + body('response_text').notEmpty().withMessage('Response text is required') + ], + handleValidationErrors, + responsesController.submitResponse +); +router.post('/responses/:id/upvote', optionalAuth, rateLimiter.general, responsesController.upvoteResponse); +router.delete('/responses/:id/upvote', optionalAuth, rateLimiter.general, responsesController.removeUpvote); + +// Admin Response Management Routes +router.get('/admin/responses', requireAdmin, rateLimiter.general, responsesController.getAdminResponses); +router.patch('/admin/responses/:id/status', requireAdmin, rateLimiter.general, + [body('status').isIn(['pending', 'approved', 'rejected']).withMessage('Invalid status')], + handleValidationErrors, + responsesController.updateResponseStatus +); +router.patch('/admin/responses/:id', requireAdmin, rateLimiter.general, responsesController.updateResponse); +router.delete('/admin/responses/:id', requireAdmin, rateLimiter.general, responsesController.deleteResponse); + +// Debug endpoint to check raw NocoDB data +router.get('/debug/responses', requireAdmin, async (req, res) => { + try { + const nocodbService = require('../services/nocodb'); + // Get raw data without normalization + const rawResult = await nocodbService.getAll(nocodbService.tableIds.representativeResponses, {}); + res.json({ + success: true, + count: rawResult.list?.length || 0, + rawResponses: rawResult.list || [], + normalized: await nocodbService.getRepresentativeResponses({}) + }); + } catch (error) { + res.status(500).json({ error: error.message, stack: error.stack }); + } +}); + module.exports = router; \ No newline at end of file diff --git a/influence/app/services/nocodb.js b/influence/app/services/nocodb.js index ea7a593..59f67ad 100644 --- a/influence/app/services/nocodb.js +++ b/influence/app/services/nocodb.js @@ -26,7 +26,9 @@ class NocoDBService { campaigns: process.env.NOCODB_TABLE_CAMPAIGNS, campaignEmails: process.env.NOCODB_TABLE_CAMPAIGN_EMAILS, users: process.env.NOCODB_TABLE_USERS, - calls: process.env.NOCODB_TABLE_CALLS + calls: process.env.NOCODB_TABLE_CALLS, + representativeResponses: process.env.NOCODB_TABLE_REPRESENTATIVE_RESPONSES, + responseUpvotes: process.env.NOCODB_TABLE_RESPONSE_UPVOTES }; // Validate that all table IDs are set @@ -688,6 +690,191 @@ class NocoDBService { return await this.getAll(this.tableIds.users, params); } + + // Representative Responses methods + async getRepresentativeResponses(params = {}) { + if (!this.tableIds.representativeResponses) { + throw new Error('Representative responses table not configured'); + } + console.log('getRepresentativeResponses params:', JSON.stringify(params, null, 2)); + const result = await this.getAll(this.tableIds.representativeResponses, params); + + // Log without the where clause to see ALL responses + if (params.where) { + const allResult = await this.getAll(this.tableIds.representativeResponses, {}); + console.log(`Total responses in DB (no filter): ${allResult.list?.length || 0}`); + if (allResult.list && allResult.list.length > 0) { + console.log('Sample raw response from DB:', JSON.stringify(allResult.list[0], null, 2)); + } + } + + console.log('getRepresentativeResponses raw result:', JSON.stringify(result, null, 2)); + // NocoDB returns {list: [...]} or {pageInfo: {...}, list: [...]} + const list = result.list || []; + console.log(`getRepresentativeResponses: Found ${list.length} responses`); + return list.map(item => this.normalizeResponse(item)); + } + + async getRepresentativeResponseById(responseId) { + if (!this.tableIds.representativeResponses) { + throw new Error('Representative responses table not configured'); + } + try { + const url = `${this.getTableUrl(this.tableIds.representativeResponses)}/${responseId}`; + const response = await this.client.get(url); + return this.normalizeResponse(response.data); + } catch (error) { + console.error('Error getting representative response by ID:', error); + throw error; + } + } + + async createRepresentativeResponse(responseData) { + if (!this.tableIds.representativeResponses) { + throw new Error('Representative responses table not configured'); + } + + // Ensure campaign_id is not null/undefined + if (!responseData.campaign_id) { + throw new Error('Campaign ID is required for creating a response'); + } + + const data = { + 'Campaign ID': responseData.campaign_id, + 'Campaign Slug': responseData.campaign_slug, + 'Representative Name': responseData.representative_name, + 'Representative Title': responseData.representative_title, + 'Representative Level': responseData.representative_level, + 'Response Type': responseData.response_type, + 'Response Text': responseData.response_text, + 'User Comment': responseData.user_comment, + 'Screenshot URL': responseData.screenshot_url, + 'Submitted By Name': responseData.submitted_by_name, + 'Submitted By Email': responseData.submitted_by_email, + 'Submitted By User ID': responseData.submitted_by_user_id, + 'Is Anonymous': responseData.is_anonymous, + 'Status': responseData.status, + 'Is Verified': responseData.is_verified, + 'Upvote Count': responseData.upvote_count, + 'Submitted IP': responseData.submitted_ip + }; + + console.log('Creating response with data:', JSON.stringify(data, null, 2)); + + const url = this.getTableUrl(this.tableIds.representativeResponses); + const response = await this.client.post(url, data); + return this.normalizeResponse(response.data); + } + + async updateRepresentativeResponse(responseId, updates) { + if (!this.tableIds.representativeResponses) { + throw new Error('Representative responses table not configured'); + } + + const data = {}; + if (updates.status !== undefined) data['Status'] = updates.status; + if (updates.is_verified !== undefined) data['Is Verified'] = updates.is_verified; + if (updates.upvote_count !== undefined) data['Upvote Count'] = updates.upvote_count; + if (updates.response_text !== undefined) data['Response Text'] = updates.response_text; + if (updates.user_comment !== undefined) data['User Comment'] = updates.user_comment; + + console.log(`Updating response ${responseId} with data:`, JSON.stringify(data, null, 2)); + + const url = `${this.getTableUrl(this.tableIds.representativeResponses)}/${responseId}`; + const response = await this.client.patch(url, data); + + console.log('NocoDB update response:', JSON.stringify(response.data, null, 2)); + + return this.normalizeResponse(response.data); + } + + async deleteRepresentativeResponse(responseId) { + if (!this.tableIds.representativeResponses) { + throw new Error('Representative responses table not configured'); + } + const url = `${this.getTableUrl(this.tableIds.representativeResponses)}/${responseId}`; + const response = await this.client.delete(url); + return response.data; + } + + // Response Upvotes methods + async getResponseUpvotes(params = {}) { + if (!this.tableIds.responseUpvotes) { + throw new Error('Response upvotes table not configured'); + } + const result = await this.getAll(this.tableIds.responseUpvotes, params); + // NocoDB returns {list: [...]} or {pageInfo: {...}, list: [...]} + const list = result.list || []; + return list.map(item => this.normalizeUpvote(item)); + } + + async createResponseUpvote(upvoteData) { + if (!this.tableIds.responseUpvotes) { + throw new Error('Response upvotes table not configured'); + } + + const data = { + 'Response ID': upvoteData.response_id, + 'User ID': upvoteData.user_id, + 'User Email': upvoteData.user_email, + 'Upvoted IP': upvoteData.upvoted_ip + }; + + const url = this.getTableUrl(this.tableIds.responseUpvotes); + const response = await this.client.post(url, data); + return this.normalizeUpvote(response.data); + } + + async deleteResponseUpvote(upvoteId) { + if (!this.tableIds.responseUpvotes) { + throw new Error('Response upvotes table not configured'); + } + const url = `${this.getTableUrl(this.tableIds.responseUpvotes)}/${upvoteId}`; + const response = await this.client.delete(url); + return response.data; + } + + // Normalize response data from NocoDB format to application format + normalizeResponse(data) { + if (!data) return null; + + return { + id: data.ID || data.Id || data.id, + campaign_id: data['Campaign ID'] || data.campaign_id, + campaign_slug: data['Campaign Slug'] || data.campaign_slug, + representative_name: data['Representative Name'] || data.representative_name, + representative_title: data['Representative Title'] || data.representative_title, + representative_level: data['Representative Level'] || data.representative_level, + response_type: data['Response Type'] || data.response_type, + response_text: data['Response Text'] || data.response_text, + user_comment: data['User Comment'] || data.user_comment, + screenshot_url: data['Screenshot URL'] || data.screenshot_url, + submitted_by_name: data['Submitted By Name'] || data.submitted_by_name, + submitted_by_email: data['Submitted By Email'] || data.submitted_by_email, + submitted_by_user_id: data['Submitted By User ID'] || data.submitted_by_user_id, + is_anonymous: data['Is Anonymous'] || data.is_anonymous || false, + status: data['Status'] || data.status, + is_verified: data['Is Verified'] || data.is_verified || false, + upvote_count: data['Upvote Count'] || data.upvote_count || 0, + submitted_ip: data['Submitted IP'] || data.submitted_ip, + created_at: data.CreatedAt || data.created_at, + updated_at: data.UpdatedAt || data.updated_at + }; + } + + // Normalize upvote data from NocoDB format to application format + normalizeUpvote(data) { + if (!data) return null; + + return { + id: data.ID || data.Id || data.id, + response_id: data['Response ID'] || data.response_id, + user_id: data['User ID'] || data.user_id, + user_email: data['User Email'] || data.user_email, + upvoted_ip: data['Upvoted IP'] || data.upvoted_ip, + created_at: data.CreatedAt || data.created_at + }; + } } -module.exports = new NocoDBService(); \ No newline at end of file +module.exports = new NocoDBService(); diff --git a/influence/app/utils/validators.js b/influence/app/utils/validators.js index 2d04749..8129e5c 100644 --- a/influence/app/utils/validators.js +++ b/influence/app/utils/validators.js @@ -78,6 +78,52 @@ function validateSlug(slug) { return slugPattern.test(slug) && slug.length >= 3 && slug.length <= 100; } +// Validate response submission +function validateResponse(data) { + const { representative_name, representative_level, response_type, response_text } = data; + + // Check required fields + if (!representative_name || representative_name.trim() === '') { + return { valid: false, error: 'Representative name is required' }; + } + + if (!representative_level || representative_level.trim() === '') { + return { valid: false, error: 'Representative level is required' }; + } + + // Validate representative level + const validLevels = ['Federal', 'Provincial', 'Municipal', 'School Board']; + if (!validLevels.includes(representative_level)) { + return { valid: false, error: 'Invalid representative level' }; + } + + if (!response_type || response_type.trim() === '') { + return { valid: false, error: 'Response type is required' }; + } + + // Validate response type + const validTypes = ['Email', 'Letter', 'Phone Call', 'Meeting', 'Social Media', 'Other']; + if (!validTypes.includes(response_type)) { + return { valid: false, error: 'Invalid response type' }; + } + + if (!response_text || response_text.trim() === '') { + return { valid: false, error: 'Response text is required' }; + } + + // Check for suspicious content + if (containsSuspiciousContent(response_text)) { + return { valid: false, error: 'Response contains invalid content' }; + } + + // Validate email if provided + if (data.submitted_by_email && !validateEmail(data.submitted_by_email)) { + return { valid: false, error: 'Invalid email address' }; + } + + return { valid: true }; +} + module.exports = { validatePostalCode, validateAlbertaPostalCode, @@ -87,5 +133,6 @@ module.exports = { validateRequiredFields, containsSuspiciousContent, generateSlug, - validateSlug + validateSlug, + validateResponse }; \ No newline at end of file diff --git a/influence/scripts/build-nocodb.sh b/influence/scripts/build-nocodb.sh index d437b92..2a32326 100755 --- a/influence/scripts/build-nocodb.sh +++ b/influence/scripts/build-nocodb.sh @@ -1330,6 +1330,201 @@ create_call_logs_table() { create_table "$base_id" "influence_call_logs" "$table_data" "Phone call tracking logs" } +# Function to create the representative responses table +create_representative_responses_table() { + local base_id=$1 + + local table_data='{ + "table_name": "influence_representative_responses", + "title": "Representative Responses", + "columns": [ + { + "column_name": "id", + "title": "ID", + "uidt": "ID", + "pk": true, + "ai": true, + "rqd": true + }, + { + "column_name": "campaign_id", + "title": "Campaign ID", + "uidt": "SingleLineText", + "rqd": true + }, + { + "column_name": "campaign_slug", + "title": "Campaign Slug", + "uidt": "SingleLineText", + "rqd": true + }, + { + "column_name": "representative_name", + "title": "Representative Name", + "uidt": "SingleLineText", + "rqd": true + }, + { + "column_name": "representative_title", + "title": "Representative Title", + "uidt": "SingleLineText", + "rqd": false + }, + { + "column_name": "representative_level", + "title": "Representative Level", + "uidt": "SingleSelect", + "rqd": true, + "colOptions": { + "options": [ + {"title": "Federal", "color": "#e74c3c"}, + {"title": "Provincial", "color": "#3498db"}, + {"title": "Municipal", "color": "#2ecc71"}, + {"title": "School Board", "color": "#f39c12"} + ] + } + }, + { + "column_name": "response_type", + "title": "Response Type", + "uidt": "SingleSelect", + "rqd": true, + "colOptions": { + "options": [ + {"title": "Email", "color": "#3498db"}, + {"title": "Letter", "color": "#9b59b6"}, + {"title": "Phone Call", "color": "#1abc9c"}, + {"title": "Meeting", "color": "#e67e22"}, + {"title": "Social Media", "color": "#34495e"}, + {"title": "Other", "color": "#95a5a6"} + ] + } + }, + { + "column_name": "response_text", + "title": "Response Text", + "uidt": "LongText", + "rqd": true + }, + { + "column_name": "user_comment", + "title": "User Comment", + "uidt": "LongText", + "rqd": false + }, + { + "column_name": "screenshot_url", + "title": "Screenshot URL", + "uidt": "SingleLineText", + "rqd": false + }, + { + "column_name": "submitted_by_name", + "title": "Submitted By Name", + "uidt": "SingleLineText", + "rqd": false + }, + { + "column_name": "submitted_by_email", + "title": "Submitted By Email", + "uidt": "Email", + "rqd": false + }, + { + "column_name": "submitted_by_user_id", + "title": "Submitted By User ID", + "uidt": "SingleLineText", + "rqd": false + }, + { + "column_name": "is_anonymous", + "title": "Is Anonymous", + "uidt": "Checkbox", + "cdf": "false" + }, + { + "column_name": "status", + "title": "Status", + "uidt": "SingleSelect", + "cdf": "pending", + "colOptions": { + "options": [ + {"title": "pending", "color": "#f39c12"}, + {"title": "approved", "color": "#2ecc71"}, + {"title": "rejected", "color": "#e74c3c"} + ] + } + }, + { + "column_name": "is_verified", + "title": "Is Verified", + "uidt": "Checkbox", + "cdf": "false" + }, + { + "column_name": "upvote_count", + "title": "Upvote Count", + "uidt": "Number", + "cdf": "0" + }, + { + "column_name": "submitted_ip", + "title": "Submitted IP", + "uidt": "SingleLineText", + "rqd": false + } + ] + }' + + create_table "$base_id" "influence_representative_responses" "$table_data" "Community responses from representatives" +} + +# Function to create the response upvotes table +create_response_upvotes_table() { + local base_id=$1 + + local table_data='{ + "table_name": "influence_response_upvotes", + "title": "Response Upvotes", + "columns": [ + { + "column_name": "id", + "title": "ID", + "uidt": "ID", + "pk": true, + "ai": true, + "rqd": true + }, + { + "column_name": "response_id", + "title": "Response ID", + "uidt": "SingleLineText", + "rqd": true + }, + { + "column_name": "user_id", + "title": "User ID", + "uidt": "SingleLineText", + "rqd": false + }, + { + "column_name": "user_email", + "title": "User Email", + "uidt": "Email", + "rqd": false + }, + { + "column_name": "upvoted_ip", + "title": "Upvoted IP", + "uidt": "SingleLineText", + "rqd": false + } + ] + }' + + create_table "$base_id" "influence_response_upvotes" "$table_data" "Track upvotes on responses" +} + # Function to create the users table create_users_table() { local base_id="$1" @@ -1451,6 +1646,8 @@ update_env_with_table_ids() { local campaign_emails_table_id=$6 local users_table_id=$7 local call_logs_table_id=$8 + local representative_responses_table_id=$9 + local response_upvotes_table_id=${10} print_status "Updating .env file with NocoDB project and table IDs..." @@ -1486,6 +1683,8 @@ update_env_with_table_ids() { update_env_var "NOCODB_TABLE_CAMPAIGN_EMAILS" "$campaign_emails_table_id" update_env_var "NOCODB_TABLE_USERS" "$users_table_id" update_env_var "NOCODB_TABLE_CALLS" "$call_logs_table_id" + update_env_var "NOCODB_TABLE_REPRESENTATIVE_RESPONSES" "$representative_responses_table_id" + update_env_var "NOCODB_TABLE_RESPONSE_UPVOTES" "$response_upvotes_table_id" print_success "Successfully updated .env file with all table IDs" @@ -1500,6 +1699,8 @@ update_env_with_table_ids() { print_status "NOCODB_TABLE_CAMPAIGN_EMAILS=$campaign_emails_table_id" print_status "NOCODB_TABLE_USERS=$users_table_id" print_status "NOCODB_TABLE_CALLS=$call_logs_table_id" + print_status "NOCODB_TABLE_REPRESENTATIVE_RESPONSES=$representative_responses_table_id" + print_status "NOCODB_TABLE_RESPONSE_UPVOTES=$response_upvotes_table_id" } @@ -1607,8 +1808,22 @@ main() { exit 1 fi + # Create representative responses table + REPRESENTATIVE_RESPONSES_TABLE_ID=$(create_representative_responses_table "$BASE_ID") + if [[ $? -ne 0 ]]; then + print_error "Failed to create representative responses table" + exit 1 + fi + + # Create response upvotes table + RESPONSE_UPVOTES_TABLE_ID=$(create_response_upvotes_table "$BASE_ID") + if [[ $? -ne 0 ]]; then + print_error "Failed to create response upvotes table" + exit 1 + fi + # Validate all table IDs were created successfully - if ! validate_table_ids "$REPRESENTATIVES_TABLE_ID" "$EMAIL_LOGS_TABLE_ID" "$POSTAL_CODES_TABLE_ID" "$CAMPAIGNS_TABLE_ID" "$CAMPAIGN_EMAILS_TABLE_ID" "$USERS_TABLE_ID" "$CALL_LOGS_TABLE_ID"; then + if ! validate_table_ids "$REPRESENTATIVES_TABLE_ID" "$EMAIL_LOGS_TABLE_ID" "$POSTAL_CODES_TABLE_ID" "$CAMPAIGNS_TABLE_ID" "$CAMPAIGN_EMAILS_TABLE_ID" "$USERS_TABLE_ID" "$CALL_LOGS_TABLE_ID" "$REPRESENTATIVE_RESPONSES_TABLE_ID" "$RESPONSE_UPVOTES_TABLE_ID"; then print_error "One or more table IDs are invalid" exit 1 fi @@ -1630,6 +1845,8 @@ main() { table_mapping["influence_campaign_emails"]="$CAMPAIGN_EMAILS_TABLE_ID" table_mapping["influence_users"]="$USERS_TABLE_ID" table_mapping["influence_call_logs"]="$CALL_LOGS_TABLE_ID" + table_mapping["influence_representative_responses"]="$REPRESENTATIVE_RESPONSES_TABLE_ID" + table_mapping["influence_response_upvotes"]="$RESPONSE_UPVOTES_TABLE_ID" # Get source table information local source_tables_response @@ -1682,6 +1899,8 @@ main() { print_status " - influence_campaign_emails (ID: $CAMPAIGN_EMAILS_TABLE_ID)" print_status " - influence_users (ID: $USERS_TABLE_ID)" print_status " - influence_call_logs (ID: $CALL_LOGS_TABLE_ID)" + print_status " - influence_representative_responses (ID: $REPRESENTATIVE_RESPONSES_TABLE_ID)" + print_status " - influence_response_upvotes (ID: $RESPONSE_UPVOTES_TABLE_ID)" # Automatically update .env file with new project ID print_status "" @@ -1704,7 +1923,7 @@ main() { fi # Update .env file with table IDs - update_env_with_table_ids "$BASE_ID" "$REPRESENTATIVES_TABLE_ID" "$EMAIL_LOGS_TABLE_ID" "$POSTAL_CODES_TABLE_ID" "$CAMPAIGNS_TABLE_ID" "$CAMPAIGN_EMAILS_TABLE_ID" "$USERS_TABLE_ID" "$CALL_LOGS_TABLE_ID" + update_env_with_table_ids "$BASE_ID" "$REPRESENTATIVES_TABLE_ID" "$EMAIL_LOGS_TABLE_ID" "$POSTAL_CODES_TABLE_ID" "$CAMPAIGNS_TABLE_ID" "$CAMPAIGN_EMAILS_TABLE_ID" "$USERS_TABLE_ID" "$CALL_LOGS_TABLE_ID" "$REPRESENTATIVE_RESPONSES_TABLE_ID" "$RESPONSE_UPVOTES_TABLE_ID" print_status "" print_status "============================================================"