# Response Wall Implementation Guide ## Overview The Response Wall is a community-driven feature that allows campaign participants to share and vote on responses they receive from elected representatives. This creates transparency, accountability, and demonstrates which representatives are actually engaging with constituents. ## User Story **As a campaign participant**, I want to share the response I received from my representative so that: - Other community members can see which representatives are responsive - Good/helpful responses get visibility through upvoting - We can hold representatives accountable publicly - Future participants know what to expect ## Feature Requirements ### Core Functionality 1. **Post Responses**: Users can submit responses they received from representatives 2. **Upvote System**: Community can upvote helpful/interesting responses 3. **Moderation**: Admin can verify, edit, or remove responses 4. **Campaign Context**: Responses are tied to specific campaigns 5. **Representative Tracking**: Track which representatives respond most/least 6. **Anonymous Option**: Users can choose to post anonymously ### Display Features - Sort by: Most Recent, Most Upvoted, Verified Responses - Filter by: Representative Level (Federal/Provincial/Municipal), Representative Name - Response cards show: Representative info, response excerpt, upvote count, date, verified badge - Pagination or infinite scroll for large volumes --- ## Implementation Plan ### Phase 1: Database Schema (NocoDB) #### New Table: `representative_responses` **Run this NocoDB setup script:** ```bash # Add to scripts/build-nocodb.sh # Create representative_responses table 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_responses", "title": "Representative Responses", "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": "user_id", "title": "User ID", "uidt": "Number", "rqd": false }, { "column_name": "representative_name", "title": "Representative Name", "uidt": "SingleLineText", "rqd": true }, { "column_name": "representative_level", "title": "Representative Level", "uidt": "SingleSelect", "dtxp": "Federal,Provincial,Municipal", "rqd": true }, { "column_name": "representative_district", "title": "District/Riding", "uidt": "SingleLineText" }, { "column_name": "response_type", "title": "Response Type", "uidt": "SingleSelect", "dtxp": "Email,Phone,Letter,Meeting,Other", "rqd": true }, { "column_name": "response_text", "title": "Response Text", "uidt": "LongText", "rqd": true }, { "column_name": "response_screenshot", "title": "Response Screenshot", "uidt": "Attachment" }, { "column_name": "user_comment", "title": "User Comment", "uidt": "LongText" }, { "column_name": "upvotes", "title": "Upvotes", "uidt": "Number", "cdf": "0" }, { "column_name": "verified", "title": "Verified", "uidt": "Checkbox", "cdf": "false" }, { "column_name": "is_anonymous", "title": "Post Anonymously", "uidt": "Checkbox", "cdf": "false" }, { "column_name": "status", "title": "Status", "uidt": "SingleSelect", "dtxp": "pending,approved,rejected", "cdf": "pending" }, { "column_name": "created_at", "title": "Created At", "uidt": "DateTime", "cdf": "CURRENT_TIMESTAMP" }, { "column_name": "updated_at", "title": "Updated At", "uidt": "DateTime", "cdf": "CURRENT_TIMESTAMP" } ] }' ``` #### New Table: `response_upvotes` **Track who upvoted what (prevent duplicate upvotes):** ```bash # Create response_upvotes table 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": "response_upvotes", "title": "Response Upvotes", "columns": [ { "column_name": "id", "title": "ID", "uidt": "ID", "pk": true, "ai": true }, { "column_name": "response_id", "title": "Response ID", "uidt": "Number", "rqd": true }, { "column_name": "user_id", "title": "User ID", "uidt": "Number", "rqd": false }, { "column_name": "ip_address", "title": "IP Address", "uidt": "SingleLineText" }, { "column_name": "created_at", "title": "Created At", "uidt": "DateTime", "cdf": "CURRENT_TIMESTAMP" } ] }' # Create unique constraint to prevent duplicate upvotes # Note: This may need to be done via NocoDB UI or direct database access ``` --- ### Phase 2: Backend API Implementation #### File: `app/controllers/responses.js` (NEW FILE) Create a new controller to handle response operations: ```javascript const nocodbService = require('../services/nocodb'); const { validateResponse } = require('../utils/validators'); /** * Get all responses for a campaign * GET /api/campaigns/:slug/responses */ async function getCampaignResponses(req, res) { try { const { slug } = req.params; const { sort = 'recent', level, representative, limit = 20, offset = 0 } = req.query; // Get campaign ID from slug const campaign = await nocodbService.getCampaignBySlug(slug); if (!campaign) { return res.status(404).json({ error: 'Campaign not found' }); } // Build filters let whereClause = `(campaign_id,eq,${campaign.Id})~and(status,eq,approved)`; if (level) { whereClause += `~and(representative_level,eq,${level})`; } if (representative) { whereClause += `~and(representative_name,like,%${representative}%)`; } // Determine sort order let sortParam = '-created_at'; // Default: newest first if (sort === 'popular') { sortParam = '-upvotes,-created_at'; } else if (sort === 'verified') { sortParam = '-verified,-upvotes,-created_at'; } // Fetch responses const responses = await nocodbService.getRecords('representative_responses', { where: whereClause, sort: sortParam, limit, offset }); // For logged-in users, check which responses they've upvoted let userUpvotes = []; if (req.user) { userUpvotes = await nocodbService.getRecords('response_upvotes', { where: `(user_id,eq,${req.user.id})`, fields: 'response_id' }); } const userUpvotedIds = userUpvotes.map(u => u.response_id); // Enrich responses with upvote status const enrichedResponses = responses.map(response => ({ ...response, user_has_upvoted: userUpvotedIds.includes(response.Id) })); res.json({ success: true, responses: enrichedResponses, pagination: { limit: parseInt(limit), offset: parseInt(offset), hasMore: responses.length === parseInt(limit) } }); } catch (error) { console.error('Error fetching campaign responses:', error); res.status(500).json({ error: 'Failed to fetch responses' }); } } /** * Submit a new response * POST /api/campaigns/:slug/responses */ async function submitResponse(req, res) { try { const { slug } = req.params; const { representative_name, representative_level, representative_district, response_type, response_text, user_comment, is_anonymous = false } = req.body; // Validate input const validation = validateResponse(req.body); if (!validation.valid) { return res.status(400).json({ error: validation.error }); } // Get campaign const campaign = await nocodbService.getCampaignBySlug(slug); if (!campaign) { return res.status(404).json({ error: 'Campaign not found' }); } // Require authentication for non-anonymous posts if (!is_anonymous && !req.user) { return res.status(401).json({ error: 'Authentication required' }); } // Handle file upload (screenshot) if present let screenshot_url = null; if (req.file) { screenshot_url = `/uploads/${req.file.filename}`; } // Create response record const responseData = { campaign_id: campaign.Id, user_id: is_anonymous ? null : req.user?.id, representative_name, representative_level, representative_district, response_type, response_text, response_screenshot: screenshot_url, user_comment, is_anonymous, upvotes: 0, verified: false, status: 'pending', // Requires admin approval created_at: new Date().toISOString() }; const newResponse = await nocodbService.createRecord('representative_responses', responseData); res.status(201).json({ success: true, message: 'Response submitted successfully. It will appear after admin approval.', response: newResponse }); } catch (error) { console.error('Error submitting response:', error); res.status(500).json({ error: 'Failed to submit response' }); } } /** * Upvote a response * POST /api/responses/:id/upvote */ async function upvoteResponse(req, res) { try { const { id } = req.params; const userId = req.user?.id; const ipAddress = req.ip || req.connection.remoteAddress; // Check if response exists const response = await nocodbService.getRecord('representative_responses', id); if (!response) { return res.status(404).json({ error: 'Response not found' }); } // Check if user/IP already upvoted let whereClause = `(response_id,eq,${id})`; if (userId) { whereClause += `~and(user_id,eq,${userId})`; } else { whereClause += `~and(ip_address,eq,${ipAddress})`; } const existingUpvote = await nocodbService.getRecords('response_upvotes', { where: whereClause, limit: 1 }); if (existingUpvote.length > 0) { return res.status(400).json({ error: 'You have already upvoted this response' }); } // Create upvote record await nocodbService.createRecord('response_upvotes', { response_id: parseInt(id), user_id: userId || null, ip_address: ipAddress, created_at: new Date().toISOString() }); // Increment upvote count const newUpvoteCount = (response.upvotes || 0) + 1; await nocodbService.updateRecord('representative_responses', id, { upvotes: newUpvoteCount }); res.json({ success: true, upvotes: newUpvoteCount, message: 'Response upvoted successfully' }); } catch (error) { console.error('Error upvoting response:', error); res.status(500).json({ error: 'Failed to upvote response' }); } } /** * Remove upvote from a response * DELETE /api/responses/:id/upvote */ async function removeUpvote(req, res) { try { const { id } = req.params; const userId = req.user?.id; const ipAddress = req.ip || req.connection.remoteAddress; // Find upvote record let whereClause = `(response_id,eq,${id})`; if (userId) { whereClause += `~and(user_id,eq,${userId})`; } else { whereClause += `~and(ip_address,eq,${ipAddress})`; } const upvotes = await nocodbService.getRecords('response_upvotes', { where: whereClause, limit: 1 }); if (upvotes.length === 0) { return res.status(400).json({ error: 'You have not upvoted this response' }); } // Delete upvote record await nocodbService.deleteRecord('response_upvotes', upvotes[0].Id); // Decrement upvote count const response = await nocodbService.getRecord('representative_responses', id); const newUpvoteCount = Math.max(0, (response.upvotes || 0) - 1); await nocodbService.updateRecord('representative_responses', id, { upvotes: newUpvoteCount }); res.json({ success: true, upvotes: newUpvoteCount, message: 'Upvote removed successfully' }); } catch (error) { console.error('Error removing upvote:', error); res.status(500).json({ error: 'Failed to remove upvote' }); } } /** * Get response statistics for a campaign * GET /api/campaigns/:slug/response-stats */ async function getResponseStats(req, res) { try { const { slug } = req.params; const campaign = await nocodbService.getCampaignBySlug(slug); if (!campaign) { return res.status(404).json({ error: 'Campaign not found' }); } const responses = await nocodbService.getRecords('representative_responses', { where: `(campaign_id,eq,${campaign.Id})~and(status,eq,approved)`, fields: 'representative_name,representative_level,verified' }); // Calculate stats const stats = { total_responses: responses.length, verified_responses: responses.filter(r => r.verified).length, by_level: { 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 }, unique_representatives: new Set(responses.map(r => r.representative_name)).size }; res.json({ success: true, stats }); } catch (error) { console.error('Error fetching response stats:', error); res.status(500).json({ error: 'Failed to fetch stats' }); } } module.exports = { getCampaignResponses, submitResponse, upvoteResponse, removeUpvote, getResponseStats }; ``` #### File: `app/routes/api.js` (UPDATE) Add response routes to existing API routes: ```javascript // Add at top with other requires const responsesController = require('../controllers/responses'); const upload = require('../middleware/upload'); // Need to create this for file uploads // Add these routes (around line 40-50, after campaign routes) // Response Wall Routes router.get('/campaigns/:slug/responses', responsesController.getCampaignResponses); router.get('/campaigns/:slug/response-stats', responsesController.getResponseStats); router.post('/campaigns/:slug/responses', optionalAuth, // Allows anonymous submissions upload.single('screenshot'), responsesController.submitResponse ); router.post('/responses/:id/upvote', optionalAuth, responsesController.upvoteResponse); router.delete('/responses/:id/upvote', optionalAuth, responsesController.removeUpvote); ``` #### File: `app/middleware/upload.js` (NEW FILE) Create file upload middleware for screenshots: ```javascript 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) => { const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9); cb(null, 'response-' + uniqueSuffix + path.extname(file.originalname)); } }); // 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 are allowed (jpeg, jpg, png, gif, webp)')); } }; // Configure multer const upload = multer({ storage: storage, limits: { fileSize: 5 * 1024 * 1024 // 5MB max }, fileFilter: fileFilter }); module.exports = upload; ``` #### File: `app/middleware/auth.js` (UPDATE) Add optional authentication middleware: ```javascript // Add this new function to existing auth.js async function optionalAuth(req, res, next) { const token = req.headers.authorization?.split(' ')[1] || req.cookies?.token; if (!token) { // No token provided, but that's OK - continue without user req.user = null; return next(); } try { const decoded = jwt.verify(token, process.env.JWT_SECRET); const user = await nocodbService.getUserById(decoded.userId); if (user) { req.user = user; } else { req.user = null; } next(); } catch (error) { // Invalid token, but continue anyway req.user = null; next(); } } // Export it module.exports = { requireAuth, requireAdmin, optionalAuth // Add this }; ``` #### File: `app/utils/validators.js` (UPDATE) Add response validation: ```javascript // Add this function to existing validators.js function validateResponse(data) { const { representative_name, representative_level, response_type, response_text } = data; if (!representative_name || representative_name.trim().length < 2) { return { valid: false, error: 'Representative name is required (min 2 characters)' }; } const validLevels = ['Federal', 'Provincial', 'Municipal']; if (!representative_level || !validLevels.includes(representative_level)) { return { valid: false, error: 'Valid representative level is required (Federal, Provincial, or Municipal)' }; } const validTypes = ['Email', 'Phone', 'Letter', 'Meeting', 'Other']; if (!response_type || !validTypes.includes(response_type)) { return { valid: false, error: 'Valid response type is required' }; } if (!response_text || response_text.trim().length < 10) { return { valid: false, error: 'Response text is required (min 10 characters)' }; } if (response_text.length > 5000) { return { valid: false, error: 'Response text is too long (max 5000 characters)' }; } return { valid: true }; } // Export it module.exports = { validateEmail, validatePostalCode, validateCampaign, validateResponse // Add this }; ``` #### File: `package.json` (UPDATE) Add multer dependency: ```json "dependencies": { // ... existing dependencies "multer": "^1.4.5-lts.1" } ``` --- ### Phase 3: Frontend Implementation #### File: `app/public/response-wall.html` (NEW FILE) Create dedicated response wall page (can be accessed standalone or embedded): ```html Community Responses - BNKops Influence

Community Response Wall

See what responses community members are getting from their representatives

0 Total Responses
0 Verified
0 Representatives
``` #### 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! 🚀