102 KiB
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
- Post Responses: Users can submit responses they received from representatives
- Upvote System: Community can upvote helpful/interesting responses
- Moderation: Admin can verify, edit, or remove responses
- Campaign Context: Responses are tied to specific campaigns
- Representative Tracking: Track which representatives respond most/least
- 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:
# 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):
# 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:
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:
// 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:
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:
// 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:
// 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:
"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):
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Community Responses - BNKops Influence</title>
<link rel="stylesheet" href="/css/styles.css">
<link rel="stylesheet" href="/css/response-wall.css">
</head>
<body>
<div class="container">
<header>
<h1>Community Response Wall</h1>
<p>See what responses community members are getting from their representatives</p>
</header>
<!-- Stats Banner -->
<div id="response-stats" class="stats-banner">
<div class="stat-item">
<span class="stat-number" id="total-responses">0</span>
<span class="stat-label">Total Responses</span>
</div>
<div class="stat-item">
<span class="stat-number" id="verified-responses">0</span>
<span class="stat-label">Verified</span>
</div>
<div class="stat-item">
<span class="stat-number" id="unique-reps">0</span>
<span class="stat-label">Representatives</span>
</div>
</div>
<!-- Filters and Sort -->
<div class="response-controls">
<div class="filter-group">
<label for="level-filter">Level:</label>
<select id="level-filter">
<option value="">All Levels</option>
<option value="Federal">Federal</option>
<option value="Provincial">Provincial</option>
<option value="Municipal">Municipal</option>
</select>
</div>
<div class="filter-group">
<label for="sort-select">Sort by:</label>
<select id="sort-select">
<option value="recent">Most Recent</option>
<option value="popular">Most Popular</option>
<option value="verified">Verified First</option>
</select>
</div>
<button id="submit-response-btn" class="btn btn-primary">
Share Your Response
</button>
</div>
<!-- Loading Spinner -->
<div id="loading" class="loading" style="display: none;">
<div class="spinner"></div>
<p>Loading responses...</p>
</div>
<!-- Responses Container -->
<div id="responses-container"></div>
<!-- Load More Button -->
<div id="load-more-container" style="display: none; text-align: center; margin-top: 2rem;">
<button id="load-more-btn" class="btn btn-secondary">Load More Responses</button>
</div>
<!-- Empty State -->
<div id="empty-state" class="empty-state" style="display: none;">
<p>No responses yet. Be the first to share your experience!</p>
<button class="btn btn-primary" onclick="openSubmitModal()">Share Your Response</button>
</div>
</div>
<!-- Submit Response Modal -->
<div id="submit-modal" class="modal" style="display: none;">
<div class="modal-content">
<span class="close" onclick="closeSubmitModal()">×</span>
<h2>Share Your Representative's Response</h2>
<form id="submit-response-form">
<div class="form-group">
<label for="rep-name">Representative Name *</label>
<input type="text" id="rep-name" required>
</div>
<div class="form-group">
<label for="rep-level">Government Level *</label>
<select id="rep-level" required>
<option value="">Select level</option>
<option value="Federal">Federal (MP)</option>
<option value="Provincial">Provincial (MLA)</option>
<option value="Municipal">Municipal (Councillor/Mayor)</option>
</select>
</div>
<div class="form-group">
<label for="rep-district">District/Riding</label>
<input type="text" id="rep-district" placeholder="e.g., Edmonton Centre">
</div>
<div class="form-group">
<label for="response-type">Response Type *</label>
<select id="response-type" required>
<option value="">Select type</option>
<option value="Email">Email</option>
<option value="Phone">Phone Call</option>
<option value="Letter">Physical Letter</option>
<option value="Meeting">In-Person Meeting</option>
<option value="Other">Other</option>
</select>
</div>
<div class="form-group">
<label for="response-text">Response Content *</label>
<textarea id="response-text" rows="6" required
placeholder="Share what your representative said or wrote..."></textarea>
<small class="text-muted">Minimum 10 characters</small>
</div>
<div class="form-group">
<label for="user-comment">Your Thoughts (Optional)</label>
<textarea id="user-comment" rows="3"
placeholder="What did you think of this response?"></textarea>
</div>
<div class="form-group">
<label for="screenshot">Upload Screenshot (Optional)</label>
<input type="file" id="screenshot" accept="image/*">
<small class="text-muted">Max 5MB. Helps verify authenticity.</small>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="is-anonymous">
Post anonymously
</label>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Submit Response</button>
<button type="button" class="btn btn-secondary" onclick="closeSubmitModal()">Cancel</button>
</div>
<p class="text-muted" style="margin-top: 1rem; font-size: 0.9rem;">
Your response will be reviewed by moderators before appearing publicly.
</p>
</form>
</div>
</div>
<script src="/js/api-client.js"></script>
<script src="/js/response-wall.js"></script>
</body>
</html>
File: app/public/css/response-wall.css (NEW FILE)
Styles for the response wall:
/* 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:
// 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 = `<span class="badge badge-level">${response.representative_level}</span>`;
badges += `<span class="badge badge-type">${response.response_type}</span>`;
if (response.verified) {
badges = `<span class="badge badge-verified">✓ Verified</span>` + badges;
}
// Build screenshot if present
let screenshotHTML = '';
if (response.response_screenshot) {
screenshotHTML = `
<div class="response-screenshot">
<img src="${response.response_screenshot}" alt="Response screenshot"
onclick="viewImage('${response.response_screenshot}')">
</div>
`;
}
// Build user comment if present
let commentHTML = '';
if (response.user_comment) {
commentHTML = `
<div class="user-comment">
<span class="user-comment-label">Community Member's Thoughts:</span>
${escapeHtml(response.user_comment)}
</div>
`;
}
// Build district info
let districtInfo = '';
if (response.representative_district) {
districtInfo = `<span>📍 ${escapeHtml(response.representative_district)}</span>`;
}
card.innerHTML = `
<div class="response-header">
<div class="response-rep-info">
<h3>${escapeHtml(response.representative_name)}</h3>
<div class="rep-meta">
${districtInfo}
</div>
</div>
<div class="response-badges">
${badges}
</div>
</div>
<div class="response-content">
<div class="response-text">
"${escapeHtml(response.response_text)}"
</div>
${commentHTML}
${screenshotHTML}
</div>
<div class="response-footer">
<div class="response-meta">
Posted ${formattedDate}
${response.is_anonymous ? ' by Anonymous' : ''}
</div>
<div class="response-actions">
<button class="upvote-btn ${response.user_has_upvoted ? 'upvoted' : ''}"
onclick="toggleUpvote(${response.Id}, this)">
<span class="upvote-icon">👍</span>
<span class="upvote-count">${response.upvotes || 0}</span>
</button>
</div>
</div>
`;
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:
<!-- Add this new section after campaigns section -->
<section id="responses-section" style="display: none;">
<h2>Response Moderation</h2>
<div class="admin-filters">
<select id="admin-response-status">
<option value="pending">Pending Approval</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
</select>
<button onclick="loadAdminResponses()" class="btn btn-secondary">Refresh</button>
</div>
<div id="admin-responses-container"></div>
</section>
File: app/public/js/admin.js (UPDATE)
Add response moderation functions:
// 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 = '<p>No responses found.</p>';
return;
}
container.innerHTML = responses.map(response => `
<div class="admin-response-card">
<h4>${response.representative_name} (${response.representative_level})</h4>
<p><strong>Response:</strong> ${response.response_text.substring(0, 200)}...</p>
${response.user_comment ? `<p><strong>User Comment:</strong> ${response.user_comment}</p>` : ''}
${response.response_screenshot ? `<img src="${response.response_screenshot}" style="max-width: 300px;">` : ''}
<div class="admin-actions">
${response.status === 'pending' ? `
<button onclick="approveResponse(${response.Id})" class="btn btn-success">Approve</button>
<button onclick="rejectResponse(${response.Id})" class="btn btn-danger">Reject</button>
` : ''}
<button onclick="toggleVerified(${response.Id}, ${response.verified})" class="btn btn-secondary">
${response.verified ? 'Unverify' : 'Verify'}
</button>
<button onclick="deleteResponse(${response.Id})" class="btn btn-danger">Delete</button>
</div>
</div>
`).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:
// 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:
// 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:
<!-- Add this section before the footer, after email composer -->
<section id="response-wall-section" style="margin-top: 3rem;">
<h2>Community Responses</h2>
<p>See what responses community members are getting from their representatives</p>
<div id="campaign-response-preview">
<!-- Shows top 3 responses -->
</div>
<button onclick="viewAllResponses()" class="btn btn-secondary" style="margin-top: 1rem;">
View All Responses
</button>
</section>
File: app/public/js/campaign.js (UPDATE)
Add response preview loading:
// 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 => `
<div class="response-preview-card">
<strong>${response.representative_name}</strong> (${response.representative_level})
<p>${response.response_text.substring(0, 150)}...</p>
<span>👍 ${response.upvotes || 0}</span>
</div>
`).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
-
Update NocoDB Schema:
cd /path/to/influence chmod +x scripts/build-nocodb.sh ./scripts/build-nocodb.sh -
Install Dependencies:
cd app npm install multer -
Create Upload Directory:
mkdir -p app/public/uploads/responses -
Rebuild Docker Container:
docker compose down docker compose build docker compose up -d -
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:
# 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:
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:
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)
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:
// 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:
// 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:
// 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:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Representative Dashboard - BNKops Influence</title>
<link rel="stylesheet" href="/css/styles.css">
<link rel="stylesheet" href="/css/representative.css">
</head>
<body>
<div class="container">
<header>
<h1>Representative Dashboard</h1>
<p>Respond to your constituents directly</p>
<div class="header-actions">
<button onclick="window.location.href='/'" class="btn btn-secondary">View Public Site</button>
<button onclick="logout()" class="btn btn-secondary">Logout</button>
</div>
</header>
<!-- Stats Dashboard -->
<div class="rep-stats-grid">
<div class="stat-card">
<div class="stat-icon">📧</div>
<div class="stat-content">
<span class="stat-number" id="total-emails">0</span>
<span class="stat-label">Total Emails Received</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">⏳</div>
<div class="stat-content">
<span class="stat-number" id="unanswered-emails">0</span>
<span class="stat-label">Awaiting Response</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">✅</div>
<div class="stat-content">
<span class="stat-number" id="answered-emails">0</span>
<span class="stat-label">Responded</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon">📊</div>
<div class="stat-content">
<span class="stat-number" id="unique-campaigns">0</span>
<span class="stat-label">Active Campaigns</span>
</div>
</div>
</div>
<!-- Verification Notice -->
<div id="verification-notice" class="notice notice-warning" style="display: none;">
<strong>⚠️ Verification Pending</strong>
<p>Your representative account is awaiting administrator verification. You'll be able to respond to emails once verified.</p>
</div>
<!-- Filters -->
<div class="inbox-controls">
<div class="filter-group">
<label for="status-filter">Show:</label>
<select id="status-filter">
<option value="">All Emails</option>
<option value="false" selected>Unanswered</option>
<option value="true">Answered</option>
</select>
</div>
<button onclick="loadInbox()" class="btn btn-secondary">Refresh</button>
</div>
<!-- Loading -->
<div id="loading" class="loading" style="display: none;">
<div class="spinner"></div>
<p>Loading inbox...</p>
</div>
<!-- Inbox Container -->
<div id="inbox-container"></div>
<!-- Load More -->
<div id="load-more-container" style="display: none; text-align: center; margin-top: 2rem;">
<button id="load-more-btn" class="btn btn-secondary">Load More</button>
</div>
<!-- Empty State -->
<div id="empty-state" class="empty-state" style="display: none;">
<p>No emails found. Constituents haven't contacted you through any campaigns yet.</p>
</div>
</div>
<!-- Response Modal -->
<div id="response-modal" class="modal" style="display: none;">
<div class="modal-content modal-large">
<span class="close" onclick="closeResponseModal()">×</span>
<h2>Respond to Constituent Email</h2>
<div id="original-email" class="original-email-box"></div>
<form id="response-form">
<input type="hidden" id="inbox-id">
<div class="form-group">
<label for="response-type">Response Type</label>
<select id="response-type">
<option value="Email">Email Response</option>
<option value="Phone">Phone Call</option>
<option value="Meeting">In-Person Meeting</option>
<option value="Letter">Written Letter</option>
</select>
</div>
<div class="form-group">
<label for="response-text">Your Official Response *</label>
<textarea id="response-text" rows="8" required
placeholder="Write your official response here..."></textarea>
<small class="text-muted">This will be publicly visible on the Response Wall with a "Verified by Representative" badge.</small>
</div>
<div class="form-group">
<label for="response-comment">Additional Context (Optional)</label>
<textarea id="response-comment" rows="3"
placeholder="Add any additional context or notes about this response..."></textarea>
</div>
<div class="form-group">
<label for="response-screenshot">Attach Screenshot (Optional)</label>
<input type="file" id="response-screenshot" accept="image/*">
<small class="text-muted">Max 5MB. Attach proof of response if applicable.</small>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">Publish Response</button>
<button type="button" class="btn btn-secondary" onclick="closeResponseModal()">Cancel</button>
</div>
<p class="text-muted" style="margin-top: 1rem;">
✓ This response will be automatically verified and published to the campaign's Response Wall.
</p>
</form>
</div>
</div>
<script src="/js/api-client.js"></script>
<script src="/js/representative-dashboard.js"></script>
</body>
</html>
File: app/public/css/representative.css (NEW FILE)
/* 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)
// 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 = `<span class="badge badge-answered">✓ Answered</span>`;
}
// Truncate email body for preview
const bodyPreview = email.email_body.length > 200
? email.email_body.substring(0, 200) + '...'
: email.email_body;
item.innerHTML = `
<div class="inbox-header">
<div class="inbox-campaign-info">
<h3>${escapeHtml(email.campaign_title)}</h3>
<div class="campaign-meta">
<span>From constituent in ${escapeHtml(email.sender_postal_code)}</span>
</div>
</div>
<div class="inbox-badges">
${badges}
</div>
</div>
<div class="inbox-body" id="body-${email.Id}">
<strong>Subject:</strong> ${escapeHtml(email.email_subject)}<br><br>
${escapeHtml(bodyPreview)}
${email.email_body.length > 200 ? '<div class="inbox-body-fade"></div>' : ''}
</div>
<div class="inbox-footer">
<div class="inbox-meta">
Received ${formattedDate}
</div>
<div class="inbox-actions">
${email.email_body.length > 200 ? `
<button class="btn btn-expand" onclick="toggleEmailBody(${email.Id})">
Read Full Email
</button>
` : ''}
${!email.has_response && isVerified ? `
<button class="btn btn-respond" onclick="openResponseModal(${email.Id})">
Respond Publicly
</button>
` : ''}
${email.has_response ? `
<button class="btn btn-secondary" onclick="viewResponse(${email.response_id})">
View Response
</button>
` : ''}
</div>
</div>
`;
// 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 = `
<strong>Subject:</strong> ${escapeHtml(email.email_subject)}<br><br>
${escapeHtml(email.email_body.substring(0, 200))}...
<div class="inbox-body-fade"></div>
`;
} else {
bodyElement.classList.add('expanded');
bodyElement.innerHTML = `
<strong>Subject:</strong> ${escapeHtml(email.email_subject)}<br><br>
${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 = `
<h4>Original Email from Constituent</h4>
<div class="email-meta">
<strong>Campaign:</strong> ${escapeHtml(email.campaign_title)}<br>
<strong>From:</strong> Constituent in ${escapeHtml(email.sender_postal_code)}<br>
<strong>Subject:</strong> ${escapeHtml(email.email_subject)}<br>
<strong>Date:</strong> ${new Date(email.created_at).toLocaleDateString()}
</div>
<div class="email-body">
${escapeHtml(email.email_body)}
</div>
`;
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:
/* 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:
// 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 = `<span class="badge badge-verified-rep">Official Response</span>`;
}
if (response.verified && !response.posted_by_representative) {
badges += `<span class="badge badge-verified">✓ Verified</span>`;
}
badges += `<span class="badge badge-level">${response.representative_level}</span>`;
badges += `<span class="badge badge-type">${response.response_type}</span>`;
// ... rest of existing code ...
}
Admin Panel Updates
File: app/public/admin.html (UPDATE)
Add representative verification section:
<!-- Add this new section after responses section -->
<section id="rep-verification-section" style="display: none;">
<h2>Representative Verification</h2>
<div class="admin-filters">
<select id="rep-verification-status">
<option value="pending">Pending Verification</option>
<option value="verified">Verified</option>
<option value="all">All Representatives</option>
</select>
<button onclick="loadRepresentatives()" class="btn btn-secondary">Refresh</button>
</div>
<div id="admin-representatives-container"></div>
</section>
File: app/public/js/admin.js (UPDATE)
Add representative verification functions:
// 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 = '<p>No representatives found.</p>';
return;
}
container.innerHTML = reps.map(rep => `
<div class="admin-rep-card">
<h4>${rep.representative_name} (${rep.representative_level})</h4>
<p><strong>District:</strong> ${rep.representative_district}</p>
<p><strong>Email:</strong> ${rep.email}</p>
<p><strong>Status:</strong> ${rep.representative_verified ? 'Verified' : 'Pending Verification'}</p>
${rep.verification_document ? `
<p><a href="${rep.verification_document}" target="_blank">View Verification Document</a></p>
` : ''}
<div class="admin-actions">
${!rep.representative_verified ? `
<button onclick="verifyRepresentative(${rep.Id})" class="btn btn-success">
Verify Representative
</button>
` : `
<button onclick="unverifyRepresentative(${rep.Id})" class="btn btn-danger">
Revoke Verification
</button>
`}
</div>
</div>
`).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:
// 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:
// 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)
- Representative Scorecards: Aggregate response data to show which reps are most responsive
- Email Notifications: Notify users when their response is approved
- Response Templates: Let users use parts of responses as email templates
- Search Functionality: Search responses by keyword
- Response Analytics: Track response sentiment, common themes
- API Rate Limiting: Add specific rate limits for response submission
- Spam Detection: Automated spam/abuse detection
- Response Categories: Tag responses (Positive, Negative, Non-Answer, etc.)
- Representative Notifications: Email reps when they receive new constituent emails
- Response Drafts: Let representatives save draft responses
- Bulk Response: Allow representatives to respond to multiple similar emails at once
- 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! 🚀