# 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
0
Total Responses
0
Verified
0
Representatives
Level:
All Levels
Federal
Provincial
Municipal
Sort by:
Most Recent
Most Popular
Verified First
Share Your Response
Load More Responses
No responses yet. Be the first to share your experience!
Share Your Response
×
Share Your Representative's Response
```
#### 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 = `
`;
}
// Build user comment if present
let commentHTML = '';
if (response.user_comment) {
commentHTML = `
`;
}
// Build district info
let districtInfo = '';
if (response.representative_district) {
districtInfo = `📍 ${escapeHtml(response.representative_district)} `;
}
card.innerHTML = `
"${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
Response Moderation
Pending Approval
Approved
Rejected
Refresh
```
#### 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' ? `
Approve
Reject
` : ''}
${response.verified ? 'Unverify' : 'Verify'}
Delete
`).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
View All Responses
```
#### 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
📧
0
Total Emails Received
⚠️ Verification Pending
Your representative account is awaiting administrator verification. You'll be able to respond to emails once verified.
Show:
All Emails
Unanswered
Answered
Refresh
Load More
No emails found. Constituents haven't contacted you through any campaigns yet.
×
Respond to Constituent Email
Response Type
Email Response
Phone Call
In-Person Meeting
Written Letter
Your Official Response *
This will be publicly visible on the Response Wall with a "Verified by Representative" badge.
Additional Context (Optional)
Attach Screenshot (Optional)
Max 5MB. Attach proof of response if applicable.
Publish Response
Cancel
✓ This response will be automatically verified and published to the campaign's Response Wall.
```
#### 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 = `
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
Representative Verification
Pending Verification
Verified
All Representatives
Refresh
```
#### 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 ? `
Verify Representative
` : `
Revoke Verification
`}
`).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! 🚀