3625 lines
102 KiB
Markdown
3625 lines
102 KiB
Markdown
# 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
|
|
<!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:
|
|
|
|
```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 = `<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:
|
|
|
|
```html
|
|
<!-- 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:
|
|
|
|
```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 = '<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:
|
|
|
|
```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
|
|
<!-- 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:
|
|
|
|
```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 => `
|
|
<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
|
|
|
|
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
|
|
<!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)
|
|
|
|
```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 = `<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:
|
|
|
|
```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 = `<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:
|
|
|
|
```html
|
|
<!-- 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:
|
|
|
|
```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 = '<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:
|
|
|
|
```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! 🚀
|