New resposne wall coding started.

This commit is contained in:
admin 2025-10-10 22:11:20 -06:00
parent 7cc6100e9b
commit ccececaf25
21 changed files with 7172 additions and 36 deletions

View File

@ -0,0 +1,191 @@
# Response Wall Admin Panel - Inline Handler Fix Summary
## Issue
Content Security Policy (CSP) violation when clicking buttons in the Response Moderation tab of the admin panel.
## Error Messages
```
Refused to execute inline event handler because it violates the following Content Security Policy directive: "script-src-attr 'none'"
TypeError: window.apiClient.patch is not a function
```
## Root Causes
### 1. Inline Event Handlers (CSP Violation)
The admin panel's Response Moderation tab was using inline `onclick` handlers:
```javascript
<button onclick="adminPanel.approveResponse(${response.id})">✓ Approve</button>
```
This violates:
- Browser Content Security Policy (CSP)
- Project development guidelines in `instruct.md`: **"No inline event handlers. Always use addEventListener in JS files."**
### 2. Missing API Client Methods
The `APIClient` class only had `get()` and `post()` methods, but admin operations needed `patch()`, `put()`, and `delete()`.
## Solutions Implemented
### Fix 1: Removed All Inline Handlers in admin.js
**Before:**
```javascript
<button class="btn btn-success btn-sm" onclick="adminPanel.approveResponse(${response.id})">
✓ Approve
</button>
```
**After:**
```javascript
<button class="btn btn-success btn-sm"
data-action="approve-response"
data-response-id="${response.id}">
✓ Approve
</button>
```
### Fix 2: Added Event Delegation
Created new `setupResponseActionListeners()` method in `AdminPanel` class:
```javascript
setupResponseActionListeners() {
const container = document.getElementById('admin-responses-container');
if (!container) return;
// Remove old listener if exists to avoid duplicates
const oldListener = container._responseActionListener;
if (oldListener) {
container.removeEventListener('click', oldListener);
}
// Create new listener with event delegation
const listener = (e) => {
const target = e.target;
const action = target.dataset.action;
const responseId = target.dataset.responseId;
if (!action || !responseId) return;
switch (action) {
case 'approve-response':
this.approveResponse(parseInt(responseId));
break;
case 'reject-response':
this.rejectResponse(parseInt(responseId));
break;
case 'verify-response':
const isVerified = target.dataset.verified === 'true';
this.toggleVerified(parseInt(responseId), isVerified);
break;
case 'delete-response':
this.deleteResponse(parseInt(responseId));
break;
}
};
// Store listener reference and add it
container._responseActionListener = listener;
container.addEventListener('click', listener);
}
```
This method is called at the end of `renderAdminResponses()` to set up listeners after the HTML is rendered.
### Fix 3: Added Missing HTTP Methods to api-client.js
```javascript
async put(endpoint, data) {
return this.makeRequest(endpoint, {
method: 'PUT',
body: JSON.stringify(data)
});
}
async patch(endpoint, data) {
return this.makeRequest(endpoint, {
method: 'PATCH',
body: JSON.stringify(data)
});
}
async delete(endpoint) {
return this.makeRequest(endpoint, {
method: 'DELETE'
});
}
```
## Buttons Fixed
All response moderation buttons now use proper event delegation:
1. **Approve** - `data-action="approve-response"`
2. **Reject** - `data-action="reject-response"`
3. **Mark as Verified** - `data-action="verify-response" data-verified="true"`
4. **Remove Verification** - `data-action="verify-response" data-verified="false"`
5. **Unpublish** - `data-action="reject-response"` (reuses reject action)
6. **Delete** - `data-action="delete-response"`
## Files Modified
1. **app/public/js/admin.js**
- Modified `renderAdminResponses()` - Replaced inline onclick with data attributes
- Added `setupResponseActionListeners()` - Event delegation implementation
- Total changes: ~85 lines modified/added
2. **app/public/js/api-client.js**
- Added `put()` method
- Added `patch()` method
- Added `delete()` method
- Total changes: ~18 lines added
## Benefits of This Approach
### 1. Security
- ✅ Complies with Content Security Policy (CSP)
- ✅ Prevents script injection attacks
- ✅ Follows modern web security best practices
### 2. Performance
- ✅ Single event listener instead of N listeners (one per button)
- ✅ Better memory usage
- ✅ Faster page rendering
### 3. Maintainability
- ✅ Follows project coding standards (instruct.md)
- ✅ Centralized event handling logic
- ✅ Easy to add new actions without HTML changes
### 4. Reliability
- ✅ Prevents duplicate listeners
- ✅ Clean listener removal/re-attachment
- ✅ Works with dynamically rendered content
## Testing Checklist
- [x] Load admin panel → Response Moderation tab
- [x] Click "Approve" button → Should approve response without CSP error
- [x] Click "Reject" button → Should reject response
- [x] Click "Mark as Verified" → Should add verification badge
- [x] Click "Remove Verification" → Should remove badge
- [x] Click "Delete" → Should delete response after confirmation
- [x] Filter responses by status → Should reload list
- [x] No console errors related to inline handlers
- [x] No CSP violations
## Lessons Learned
1. **Always Follow Project Guidelines**: The instruct.md file explicitly prohibits inline event handlers - following it from the start would have prevented this issue
2. **Complete API Client**: When building a REST client, implement all HTTP methods (GET, POST, PUT, PATCH, DELETE) from the beginning
3. **Event Delegation for Dynamic Content**: When rendering content dynamically with buttons/links, always use event delegation on a parent container
4. **CSP is Your Friend**: Content Security Policy errors point to real security issues - fix them rather than disabling CSP
## Related Documentation
- See `instruct.md` - Development Rules section: "No inline event handlers"
- See `RESPONSE_WALL_FIXES.md` - Full list of all Response Wall bug fixes
- See `RESPONSE_WALL_USAGE.md` - How to use the Response Wall feature

View File

@ -10,6 +10,7 @@ A comprehensive web application that helps Alberta residents connect with their
- **Direct Email**: Built-in email composer to contact representatives
- **Campaign Management**: Create and manage advocacy campaigns with customizable settings
- **Public Campaigns Grid**: Homepage display of all active campaigns for easy discovery and participation
- **Response Wall**: Community-driven platform for sharing and voting on representative responses
- **Email Count Display**: Optional engagement metrics showing total emails sent per campaign
- **Smart Caching**: Fast performance with NocoDB caching and graceful fallback to live API
- **Responsive Design**: Works seamlessly on desktop and mobile devices
@ -406,6 +407,33 @@ influence/
- **Target Levels**: Select which government levels to target (Federal/Provincial/Municipal/School Board)
- **Campaign Status**: Draft, Active, Paused, or Archived workflow states
### Response Wall Feature
The Response Wall creates transparency and accountability by allowing campaign participants to share responses they receive from elected representatives.
**Key Features:**
- **Public Response Sharing**: Constituents can post responses received via email, letter, phone, meetings, or social media
- **Community Voting**: Upvote system highlights helpful and representative responses
- **Verification System**: Admin-moderated verification badges for authentic responses
- **Screenshot Support**: Upload visual proof of responses (images up to 5MB)
- **Anonymous Posting**: Option to share responses without revealing identity
- **Filtering & Sorting**: Filter by government level, sort by recent/upvotes/verified
- **Engagement Statistics**: Track total responses, verified count, and community upvotes
- **Moderation Queue**: Admin panel for approving, rejecting, or verifying submitted responses
- **Campaign Integration**: Response walls linked to specific campaigns for contextualized feedback
**Access Response Wall:**
- Via campaign page: Add `?campaign=your-campaign-slug` parameter to `/response-wall.html`
- Admin moderation: Navigate to "Response Moderation" tab in admin panel
- Public viewing: All approved responses visible to encourage participation
**Moderation Workflow:**
1. Users submit responses with required details (representative name, level, type, response text)
2. Submissions enter "pending" status in moderation queue
3. Admins review and approve/reject from admin panel
4. Approved responses appear on public Response Wall
5. Admins can mark verified responses with special badge
6. Community upvotes highlight most impactful responses
### Email Integration
- Modal-based email composer
- Pre-filled recipient information

View File

@ -0,0 +1,181 @@
# Response Wall Bug Fixes
## Issues Identified and Fixed
### 1. **TypeError: responses.filter is not a function**
**Error Location**: `app/controllers/responses.js:292` in `getResponseStats()`
**Root Cause**: The `getRepresentativeResponses()` method in `nocodb.js` was returning the raw NocoDB API response object `{list: [...], pageInfo: {...}}` instead of an array. The controller code expected an array and tried to call `.filter()` on an object.
**Fix Applied**: Modified `getRepresentativeResponses()` to extract the `list` array from the response and normalize each item:
```javascript
async getRepresentativeResponses(params = {}) {
if (!this.tableIds.representativeResponses) {
throw new Error('Representative responses table not configured');
}
const result = await this.getAll(this.tableIds.representativeResponses, params);
// NocoDB returns {list: [...]} or {pageInfo: {...}, list: [...]}
const list = result.list || [];
return list.map(item => this.normalizeResponse(item));
}
```
### 2. **TypeError: responses.map is not a function**
**Error Location**: `app/controllers/responses.js:51` in `getCampaignResponses()`
**Root Cause**: Same as issue #1 - the method was returning an object instead of an array.
**Fix Applied**: Same fix as above ensures an array is always returned.
### 3. **Database Error: "A value is required for this field" (code 23502)**
**Error Location**: NocoDB database constraint violation when creating a response
**Root Cause**: The `campaign_id` field was being set to `null` or `undefined`. Investigation revealed that:
- The campaign object from NocoDB uses `Id` (capital I) as the primary key field
- The controller was trying to access `campaign.id` (lowercase) which returned `undefined`
- NocoDB's representative_responses table has `campaign_id` marked as required (`"rqd": true`)
**Fix Applied**:
1. Updated `submitResponse()` controller to check multiple possible field names:
```javascript
campaign_id: campaign.Id || campaign.id || campaign['Campaign ID'],
```
2. Added validation in `createRepresentativeResponse()` to fail fast if campaign_id is missing:
```javascript
if (!responseData.campaign_id) {
throw new Error('Campaign ID is required for creating a response');
}
```
3. Added debug logging to track the campaign_id value:
```javascript
console.log('Submitting response with campaign_id:', newResponse.campaign_id, 'from campaign:', campaign);
```
### 4. **Array Handling in Response Upvotes**
**Potential Issue**: The `getResponseUpvotes()` method had the same array vs object issue
**Fix Applied**: Updated the method to return a normalized array:
```javascript
async getResponseUpvotes(params = {}) {
if (!this.tableIds.responseUpvotes) {
throw new Error('Response upvotes table not configured');
}
const result = await this.getAll(this.tableIds.responseUpvotes, params);
// NocoDB returns {list: [...]} or {pageInfo: {...}, list: [...]}
const list = result.list || [];
return list.map(item => this.normalizeUpvote(item));
}
```
## Files Modified
1. **app/services/nocodb.js**
- Modified `getRepresentativeResponses()` - Extract and normalize list
- Modified `getResponseUpvotes()` - Extract and normalize list
- Modified `createRepresentativeResponse()` - Add campaign_id validation and logging
2. **app/controllers/responses.js**
- Modified `submitResponse()` - Handle multiple campaign ID field name variations (ID, Id, id)
- Added debug logging for campaign_id
3. **app/public/js/admin.js**
- Modified `renderAdminResponses()` - Removed all inline onclick handlers, replaced with data-action attributes
- Added `setupResponseActionListeners()` - Event delegation for response moderation buttons
- Follows instruct.md guidelines: "No inline event handlers. Always use addEventListener in JS files."
4. **app/public/js/api-client.js**
- Added `put()` method for HTTP PUT requests
- Added `patch()` method for HTTP PATCH requests
- Added `delete()` method for HTTP DELETE requests
- These methods were missing and causing errors in admin panel operations
## Testing
After applying these fixes, test the following:
1. **Load Response Wall** - Visit `http://localhost:3333/response-wall.html?campaign=test-page`
- Stats should load without errors
- Response list should load without errors
2. **Submit Response** - Fill out and submit the response form
- Should successfully create a response in pending status
- Should return a success message
- Check logs for "Submitting response with campaign_id: [number]"
3. **Upvote Response** - Click the upvote button on an approved response
- Should increment the upvote count
- Should prevent duplicate upvotes
4. **Admin Moderation** - Visit `http://localhost:3333/admin.html` → Response Moderation tab
- Should see pending responses
- Should be able to approve/reject responses
## Deployment
The application container has been restarted with:
```bash
docker compose restart app
```
All fixes are now live and ready for testing.
## Root Cause Analysis
The main issue was a misunderstanding of NocoDB's API response structure:
- **Expected**: Array of records directly
- **Actual**: Object with `{list: [records], pageInfo: {...}}`
This is a common pattern in REST APIs for pagination support. The fix ensures all service methods return properly normalized arrays for consistent usage throughout the application.
The secondary issue was field naming inconsistency:
- **NocoDB Primary Key**: Uses `ID` (all caps) not `Id` or `id`
- **Application Code**: Expected `id` (lowercase)
The fix handles all three variations to ensure compatibility: `campaign.ID || campaign.Id || campaign.id`
### 5. **CSP Violation: Inline Event Handlers in Admin Panel**
**Error**: "Refused to execute inline event handler because it violates the following Content Security Policy directive: 'script-src-attr 'none''"
**Root Cause**: The `renderAdminResponses()` method in admin.js was using inline `onclick` handlers like:
```javascript
<button onclick="adminPanel.approveResponse(${response.id})">
```
This violates the development guidelines in instruct.md which explicitly state: **"No inline event handlers. Always use addEventListener in JS files."**
**Fix Applied**:
- Replaced all inline onclick handlers with data-action attributes
- Created `setupResponseActionListeners()` method using event delegation
- All buttons now use pattern: `data-action="approve-response" data-response-id="${response.id}"`
- Event delegation listens on container and routes actions based on data attributes
### 6. **Missing HTTP Methods in API Client**
**Error**: "TypeError: window.apiClient.patch is not a function"
**Root Cause**: The APIClient class in api-client.js only had `get()` and `post()` methods. Admin panel operations needed `patch()`, `put()`, and `delete()` methods for updating and deleting responses.
**Fix Applied**: Added three new methods to APIClient:
```javascript
async put(endpoint, data) { ... }
async patch(endpoint, data) { ... }
async delete(endpoint) { ... }
```
## Prevention
To prevent similar issues in the future:
1. **Type Safety**: Consider adding TypeScript or JSDoc type annotations
2. **Consistent Normalization**: Always normalize data at the service layer
3. **Field Name Standards**: Document NocoDB field naming conventions
4. **Validation**: Add validation for required fields early in the request flow
5. **Logging**: Continue adding debug logging for data transformations
## Related Documentation
- See `RESPONSE_WALL_USAGE.md` for usage instructions
- See `RESPONSE_WALL_IMPLEMENTATION.md` for feature implementation details
- See `scripts/build-nocodb.sh` for database schema definitions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,204 @@
# Response Wall - Usage Guide
## Overview
The Response Wall is now fully implemented! It allows campaign participants to share and vote on responses they receive from elected representatives.
## How to Access the Response Wall
The Response Wall requires a campaign slug to function. You must access it with a URL parameter:
### URL Format
```
http://localhost:3333/response-wall.html?campaign=YOUR-CAMPAIGN-SLUG
```
### Example URLs
```
http://localhost:3333/response-wall.html?campaign=climate-action
http://localhost:3333/response-wall.html?campaign=healthcare-reform
http://localhost:3333/response-wall.html?campaign=education-funding
```
## Setup Instructions
### 1. Create a Campaign First
Before using the Response Wall, you need to have an active campaign:
1. Go to http://localhost:3333/admin.html
2. Create a new campaign with a slug (e.g., "climate-action")
3. Note the campaign slug you created
### 2. Access the Response Wall
Use the campaign slug in the URL:
```
http://localhost:3333/response-wall.html?campaign=YOUR-SLUG-HERE
```
## Features
### For Public Users
- **View Responses**: See all approved responses from representatives
- **Filter & Sort**:
- Filter by government level (Federal, Provincial, Municipal, School Board)
- Sort by Most Recent, Most Upvoted, or Verified First
- **Submit Responses**: Share responses you've received from representatives
- Required: Representative name, level, response type, response text
- Optional: Representative title, your comment, screenshot, your name/email
- Can post anonymously
- **Upvote Responses**: Show appreciation for helpful responses
- **View Statistics**: See total responses, verified count, and total upvotes
### For Administrators
Access via the admin panel at http://localhost:3333/admin.html:
1. Navigate to the "Response Moderation" tab
2. Filter by status: Pending, Approved, Rejected, or All
3. Moderate submissions:
- **Approve**: Make response visible to public
- **Reject**: Hide inappropriate responses
- **Verify**: Add verification badge to authentic responses
- **Edit**: Modify response content if needed
- **Delete**: Remove responses permanently
## Moderation Workflow
1. User submits a response (status: "pending")
2. Admin reviews in admin panel → "Response Moderation" tab
3. Admin approves → Response appears on public Response Wall
4. Admin can mark as "verified" for authenticity badge
5. Public can upvote helpful responses
## Integration with Campaigns
### Link from Campaign Pages
You can add links to the Response Wall from your campaign pages:
```html
<a href="/response-wall.html?campaign=YOUR-SLUG">
View Community Responses
</a>
```
### Embed in Campaign Flow
Consider adding a "Share Your Response" call-to-action after users send emails to representatives.
## Testing the Feature
### 1. Create Test Campaign
```bash
# Via admin panel or API
POST /api/admin/campaigns
{
"title": "Test Campaign",
"slug": "test-campaign",
"email_subject": "Test",
"email_body": "Test body"
}
```
### 2. Submit Test Response
Navigate to:
```
http://localhost:3333/response-wall.html?campaign=test-campaign
```
Click "Share a Response" and fill out the form.
### 3. Moderate Response
1. Go to admin panel: http://localhost:3333/admin.html
2. Click "Response Moderation" tab
3. Change filter to "Pending"
4. Approve the test response
### 4. View Public Response
Refresh the Response Wall page to see your approved response.
## API Endpoints
### Public Endpoints
- `GET /api/campaigns/:slug/responses` - Get approved responses
- `GET /api/campaigns/:slug/response-stats` - Get statistics
- `POST /api/campaigns/:slug/responses` - Submit new response
- `POST /api/responses/:id/upvote` - Upvote a response
- `DELETE /api/responses/:id/upvote` - Remove upvote
### Admin Endpoints (require authentication)
- `GET /api/admin/responses` - Get all responses (any status)
- `PATCH /api/admin/responses/:id/status` - Update status
- `PATCH /api/admin/responses/:id` - Update response details
- `DELETE /api/admin/responses/:id` - Delete response
## Database Tables
The feature uses two new NocoDB tables:
### `influence_representative_responses`
Stores submitted responses with fields:
- Representative details (name, title, level)
- Response content (type, text, screenshot)
- Submitter info (name, email, anonymous flag)
- Moderation (status, verified flag)
- Engagement (upvote count)
### `influence_response_upvotes`
Tracks upvotes with fields:
- Response ID
- User ID / Email (if authenticated)
- IP address (for anonymous upvotes)
## Troubleshooting
### "No campaign specified" Error
**Problem**: Accessing `/response-wall.html` without campaign parameter
**Solution**: Add `?campaign=YOUR-SLUG` to the URL
### No Responses Showing
**Problem**: Responses exist but not visible
**Possible Causes**:
1. Responses are in "pending" status (not yet approved by admin)
2. Wrong campaign slug in URL
3. Responses filtered out by current filter settings
**Solution**:
- Check admin panel → Response Moderation tab
- Verify campaign slug matches
- Clear filters (set to "All Levels")
### Button Not Working
**Problem**: "Share a Response" button doesn't open modal
**Solution**:
- Check browser console for JavaScript errors
- Verify you're accessing with a valid campaign slug
- Hard refresh the page (Ctrl+F5)
### Images Not Uploading
**Problem**: Screenshot upload fails
**Possible Causes**:
1. File too large (max 5MB)
2. Wrong file type (only JPEG/PNG/GIF/WebP allowed)
3. Upload directory permissions
**Solution**:
- Resize image to under 5MB
- Convert to supported format
- Check `/app/public/uploads/responses/` directory exists with write permissions
## Next Steps
1. **Run Database Setup**: `./scripts/build-nocodb.sh` (if not already done)
2. **Restart Application**: `docker compose down && docker compose up --build`
3. **Create Test Campaign**: Via admin panel
4. **Test Response Submission**: Submit and moderate a test response
5. **Integrate with Campaigns**: Add Response Wall links to your campaign pages
## Notes
- All submissions require admin moderation (status: "pending" → "approved")
- Anonymous upvotes are tracked by IP address to prevent duplicates
- Authenticated upvotes are tracked by user ID
- Screenshots are stored in `/app/public/uploads/responses/`
- The feature respects NocoDB best practices (column titles, system fields, etc.)

View File

@ -0,0 +1,467 @@
const nocodbService = require('../services/nocodb');
const { validateResponse } = require('../utils/validators');
/**
* Get all responses for a campaign
* Public endpoint - no authentication required
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
async function getCampaignResponses(req, res) {
try {
const { slug } = req.params;
const { sort = 'recent', level = '', offset = 0, limit = 20, status = 'approved' } = req.query;
// Get campaign by slug first
const campaign = await nocodbService.getCampaignBySlug(slug);
if (!campaign) {
return res.status(404).json({ error: 'Campaign not found' });
}
// Build filter conditions
// NocoDB v2 API requires ~and operator between multiple conditions
let whereConditions = [
`(Campaign Slug,eq,${slug})~and(Status,eq,${status})`
];
if (level) {
whereConditions[0] += `~and(Representative Level,eq,${level})`;
}
// Build sort order
let sortOrder = '-CreatedAt'; // Default to most recent
if (sort === 'upvotes') {
sortOrder = '-Upvote Count';
} else if (sort === 'verified') {
sortOrder = '-Is Verified,-CreatedAt';
}
// Fetch responses
const responses = await nocodbService.getRepresentativeResponses({
where: whereConditions[0], // Use the first (and only) element
sort: sortOrder,
offset: parseInt(offset),
limit: parseInt(limit)
});
console.log(`Found ${responses.length} responses for campaign ${slug} with status ${status}`);
if (responses.length > 0) {
console.log('First response sample:', JSON.stringify(responses[0], null, 2));
}
// For each response, check if current user has upvoted
const userId = req.user?.id;
const userEmail = req.user?.email;
const responsesWithUpvoteStatus = await Promise.all(responses.map(async (response) => {
let hasUpvoted = false;
if (userId || userEmail) {
const upvotes = await nocodbService.getResponseUpvotes({
where: `(Response ID,eq,${response.id})`
});
hasUpvoted = upvotes.some(upvote =>
(userId && upvote.user_id === userId) ||
(userEmail && upvote.user_email === userEmail)
);
}
return {
...response,
hasUpvoted
};
}));
res.json({
success: true,
responses: responsesWithUpvoteStatus,
pagination: {
offset: parseInt(offset),
limit: parseInt(limit),
hasMore: responses.length === parseInt(limit)
}
});
} catch (error) {
console.error('Error getting campaign responses:', error);
res.status(500).json({ error: 'Failed to get campaign responses' });
}
}
/**
* Submit a new response
* Optional authentication - allows anonymous submissions
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
async function submitResponse(req, res) {
try {
const { slug } = req.params;
const responseData = req.body;
// Get campaign by slug first
const campaign = await nocodbService.getCampaignBySlug(slug);
if (!campaign) {
return res.status(404).json({ error: 'Campaign not found' });
}
// Validate response data
const validation = validateResponse(responseData);
if (!validation.valid) {
return res.status(400).json({ error: validation.error });
}
// Handle file upload if present
let screenshotUrl = null;
if (req.file) {
screenshotUrl = `/uploads/responses/${req.file.filename}`;
}
// Prepare response data for NocoDB
const newResponse = {
campaign_id: campaign.ID || campaign.Id || campaign.id || campaign['Campaign ID'],
campaign_slug: slug,
representative_name: responseData.representative_name,
representative_title: responseData.representative_title || null,
representative_level: responseData.representative_level,
response_type: responseData.response_type,
response_text: responseData.response_text,
user_comment: responseData.user_comment || null,
screenshot_url: screenshotUrl,
submitted_by_name: responseData.submitted_by_name || null,
submitted_by_email: responseData.submitted_by_email || null,
submitted_by_user_id: req.user?.id || null,
is_anonymous: responseData.is_anonymous || false,
status: 'pending', // All submissions start as pending
is_verified: false,
upvote_count: 0,
submitted_ip: req.ip || req.connection.remoteAddress
};
console.log('Submitting response with campaign_id:', newResponse.campaign_id, 'from campaign:', campaign);
// Create response in database
const createdResponse = await nocodbService.createRepresentativeResponse(newResponse);
res.status(201).json({
success: true,
message: 'Response submitted successfully. It will be visible after moderation.',
response: createdResponse
});
} catch (error) {
console.error('Error submitting response:', error);
res.status(500).json({ error: 'Failed to submit response' });
}
}
/**
* Upvote a response
* Optional authentication - allows anonymous upvotes with IP tracking
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
async function upvoteResponse(req, res) {
try {
const { id } = req.params;
// Get response
const response = await nocodbService.getRepresentativeResponseById(id);
if (!response) {
return res.status(404).json({ error: 'Response not found' });
}
const userId = req.user?.id;
const userEmail = req.user?.email;
const userIp = req.ip || req.connection.remoteAddress;
// Check if already upvoted
const existingUpvotes = await nocodbService.getResponseUpvotes({
where: `(Response ID,eq,${id})`
});
const alreadyUpvoted = existingUpvotes.some(upvote =>
(userId && upvote.user_id === userId) ||
(userEmail && upvote.user_email === userEmail) ||
(upvote.upvoted_ip === userIp)
);
if (alreadyUpvoted) {
return res.status(400).json({ error: 'You have already upvoted this response' });
}
// Create upvote
await nocodbService.createResponseUpvote({
response_id: id,
user_id: userId || null,
user_email: userEmail || null,
upvoted_ip: userIp
});
// Increment upvote count
const newCount = (response.upvote_count || 0) + 1;
await nocodbService.updateRepresentativeResponse(id, {
upvote_count: newCount
});
res.json({
success: true,
message: 'Response upvoted successfully',
upvoteCount: newCount
});
} catch (error) {
console.error('Error upvoting response:', error);
res.status(500).json({ error: 'Failed to upvote response' });
}
}
/**
* Remove upvote from a response
* Optional authentication
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
async function removeUpvote(req, res) {
try {
const { id } = req.params;
// Get response
const response = await nocodbService.getRepresentativeResponseById(id);
if (!response) {
return res.status(404).json({ error: 'Response not found' });
}
const userId = req.user?.id;
const userEmail = req.user?.email;
const userIp = req.ip || req.connection.remoteAddress;
// Find upvote to remove
const upvotes = await nocodbService.getResponseUpvotes({
where: `(Response ID,eq,${id})`
});
const upvoteToRemove = upvotes.find(upvote =>
(userId && upvote.user_id === userId) ||
(userEmail && upvote.user_email === userEmail) ||
(upvote.upvoted_ip === userIp)
);
if (!upvoteToRemove) {
return res.status(400).json({ error: 'You have not upvoted this response' });
}
// Delete upvote
await nocodbService.deleteResponseUpvote(upvoteToRemove.id);
// Decrement upvote count
const newCount = Math.max((response.upvote_count || 0) - 1, 0);
await nocodbService.updateRepresentativeResponse(id, {
upvote_count: newCount
});
res.json({
success: true,
message: 'Upvote removed successfully',
upvoteCount: newCount
});
} catch (error) {
console.error('Error removing upvote:', error);
res.status(500).json({ error: 'Failed to remove upvote' });
}
}
/**
* Get response statistics for a campaign
* Public endpoint
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
async function getResponseStats(req, res) {
try {
const { slug } = req.params;
// Get campaign by slug first
const campaign = await nocodbService.getCampaignBySlug(slug);
if (!campaign) {
return res.status(404).json({ error: 'Campaign not found' });
}
// Get all approved responses for this campaign
const responses = await nocodbService.getRepresentativeResponses({
where: `(Campaign Slug,eq,${slug}),(Status,eq,approved)`
});
// Calculate stats
const totalResponses = responses.length;
const verifiedResponses = responses.filter(r => r.is_verified).length;
const totalUpvotes = responses.reduce((sum, r) => sum + (r.upvote_count || 0), 0);
// Count by level
const byLevel = {
Federal: responses.filter(r => r.representative_level === 'Federal').length,
Provincial: responses.filter(r => r.representative_level === 'Provincial').length,
Municipal: responses.filter(r => r.representative_level === 'Municipal').length,
'School Board': responses.filter(r => r.representative_level === 'School Board').length
};
res.json({
success: true,
stats: {
totalResponses,
verifiedResponses,
totalUpvotes,
byLevel
}
});
} catch (error) {
console.error('Error getting response stats:', error);
res.status(500).json({ error: 'Failed to get response statistics' });
}
}
// Admin functions
/**
* Get all responses (admin only - includes pending/rejected)
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
async function getAdminResponses(req, res) {
try {
const { status = 'all', offset = 0, limit = 50 } = req.query;
console.log(`Admin fetching responses with status filter: ${status}`);
let whereConditions = [];
if (status !== 'all') {
whereConditions.push(`(Status,eq,${status})`);
}
const responses = await nocodbService.getRepresentativeResponses({
where: whereConditions.join(','),
sort: '-CreatedAt',
offset: parseInt(offset),
limit: parseInt(limit)
});
console.log(`Admin found ${responses.length} total responses`);
if (responses.length > 0) {
console.log('Response statuses:', responses.map(r => `ID:${r.id} Status:${r.status}`).join(', '));
}
res.json({
success: true,
responses,
pagination: {
offset: parseInt(offset),
limit: parseInt(limit),
hasMore: responses.length === parseInt(limit)
}
});
} catch (error) {
console.error('Error getting admin responses:', error);
res.status(500).json({ error: 'Failed to get responses' });
}
}
/**
* Update response status (admin only)
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
async function updateResponseStatus(req, res) {
try {
const { id } = req.params;
const { status } = req.body;
console.log(`Updating response ${id} to status: ${status}`);
if (!['pending', 'approved', 'rejected'].includes(status)) {
return res.status(400).json({ error: 'Invalid status' });
}
const updated = await nocodbService.updateRepresentativeResponse(id, { status });
console.log('Updated response:', JSON.stringify(updated, null, 2));
res.json({
success: true,
message: `Response ${status} successfully`
});
} catch (error) {
console.error('Error updating response status:', error);
res.status(500).json({ error: 'Failed to update response status' });
}
}
/**
* Update response (admin only)
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
async function updateResponse(req, res) {
try {
const { id } = req.params;
const updates = req.body;
await nocodbService.updateRepresentativeResponse(id, updates);
res.json({
success: true,
message: 'Response updated successfully'
});
} catch (error) {
console.error('Error updating response:', error);
res.status(500).json({ error: 'Failed to update response' });
}
}
/**
* Delete response (admin only)
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
async function deleteResponse(req, res) {
try {
const { id } = req.params;
// Delete all upvotes for this response first
const upvotes = await nocodbService.getResponseUpvotes({
where: `(Response ID,eq,${id})`
});
for (const upvote of upvotes) {
await nocodbService.deleteResponseUpvote(upvote.id);
}
// Delete the response
await nocodbService.deleteRepresentativeResponse(id);
res.json({
success: true,
message: 'Response deleted successfully'
});
} catch (error) {
console.error('Error deleting response:', error);
res.status(500).json({ error: 'Failed to delete response' });
}
}
module.exports = {
getCampaignResponses,
submitResponse,
upvoteResponse,
removeUpvote,
getResponseStats,
getAdminResponses,
updateResponseStatus,
updateResponse,
deleteResponse
};

View File

@ -162,8 +162,35 @@ const requireNonTemp = async (req, res, next) => {
}
};
// Optional authentication - sets req.user if authenticated, but doesn't block if not
const optionalAuth = async (req, res, next) => {
const isAuthenticated = (req.session && req.session.authenticated) ||
(req.session && req.session.userId && req.session.userEmail);
if (isAuthenticated) {
// Check if temp user has expired
const expirationResponse = await checkTempUserExpiration(req, res);
if (expirationResponse) {
return; // Response already sent by checkTempUserExpiration
}
// Set up req.user object for controllers that expect it
req.user = {
id: req.session.userId,
email: req.session.userEmail,
isAdmin: req.session.isAdmin || false,
userType: req.session.userType || 'user',
name: req.session.userName || req.session.user_name || null
};
}
// Continue regardless of authentication status
next();
};
module.exports = {
requireAuth,
requireAdmin,
requireNonTemp
requireNonTemp,
optionalAuth
};

View File

@ -0,0 +1,47 @@
const multer = require('multer');
const path = require('path');
const fs = require('fs');
// Ensure uploads directory exists
const uploadDir = path.join(__dirname, '../public/uploads/responses');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
// Configure storage
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, uploadDir);
},
filename: (req, file, cb) => {
// Generate unique filename: timestamp-random-originalname
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
const ext = path.extname(file.originalname);
const basename = path.basename(file.originalname, ext);
cb(null, `response-${uniqueSuffix}${ext}`);
}
});
// File filter - only images
const fileFilter = (req, file, cb) => {
const allowedTypes = /jpeg|jpg|png|gif|webp/;
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
const mimetype = allowedTypes.test(file.mimetype);
if (mimetype && extname) {
return cb(null, true);
} else {
cb(new Error('Only image files (JPEG, PNG, GIF, WebP) are allowed'));
}
};
// Configure multer
const upload = multer({
storage: storage,
limits: {
fileSize: 5 * 1024 * 1024 // 5MB max file size
},
fileFilter: fileFilter
});
module.exports = upload;

View File

@ -726,6 +726,7 @@
<button class="nav-btn active" data-tab="campaigns">Campaigns</button>
<button class="nav-btn" data-tab="create">Create Campaign</button>
<button class="nav-btn" data-tab="edit">Edit Campaign</button>
<button class="nav-btn" data-tab="responses">Response Moderation</button>
<button class="nav-btn" data-tab="users">User Management</button>
</nav>
@ -991,6 +992,28 @@ Sincerely,
</form>
</div>
<!-- Response Moderation Tab -->
<div id="responses-tab" class="tab-content">
<div class="form-row" style="align-items: center; margin-bottom: 2rem;">
<h2 style="margin: 0;">Response Moderation</h2>
<select id="admin-response-status" class="form-control" style="max-width: 200px; margin-left: auto;">
<option value="pending">Pending</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
<option value="all">All</option>
</select>
</div>
<div id="responses-loading" class="loading hidden">
<div class="spinner"></div>
<p>Loading responses...</p>
</div>
<div id="admin-responses-container">
<!-- Responses will be loaded here -->
</div>
</div>
<!-- User Management Tab -->
<div id="users-tab" class="tab-content">
<div class="form-row" style="align-items: center; margin-bottom: 2rem;">

View File

@ -0,0 +1,359 @@
/* Response Wall Styles */
.stats-banner {
display: flex;
justify-content: space-around;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 2rem;
border-radius: 8px;
margin-bottom: 2rem;
}
.stat-item {
text-align: center;
}
.stat-number {
display: block;
font-size: 2.5rem;
font-weight: bold;
margin-bottom: 0.5rem;
}
.stat-label {
display: block;
font-size: 0.9rem;
opacity: 0.9;
}
.response-controls {
display: flex;
justify-content: space-between;
margin-bottom: 2rem;
flex-wrap: wrap;
gap: 1rem;
align-items: center;
}
.filter-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.filter-group label {
font-weight: 600;
margin-bottom: 0;
}
.filter-group select {
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
#submit-response-btn {
margin-left: auto;
}
/* Response Card */
.response-card {
background: white;
border: 1px solid #e1e8ed;
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 1.5rem;
transition: box-shadow 0.3s ease;
}
.response-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.response-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
}
.response-rep-info {
flex: 1;
}
.response-rep-info h3 {
margin: 0 0 0.25rem 0;
color: #1a202c;
font-size: 1.2rem;
}
.response-rep-info .rep-meta {
color: #7f8c8d;
font-size: 0.9rem;
}
.response-rep-info .rep-meta span {
margin-right: 1rem;
}
.response-badges {
display: flex;
gap: 0.5rem;
}
.badge {
padding: 0.25rem 0.75rem;
border-radius: 12px;
font-size: 0.85rem;
font-weight: 600;
}
.badge-verified {
background: #27ae60;
color: white;
}
.badge-level {
background: #3498db;
color: white;
}
.badge-type {
background: #95a5a6;
color: white;
}
.response-content {
margin-bottom: 1rem;
}
.response-text {
background: #f8f9fa;
padding: 1rem;
border-left: 4px solid #3498db;
border-radius: 4px;
margin-bottom: 1rem;
white-space: pre-wrap;
line-height: 1.6;
}
.user-comment {
padding: 0.75rem;
background: #fff3cd;
border-left: 4px solid #ffc107;
border-radius: 4px;
margin-bottom: 1rem;
}
.user-comment-label {
font-weight: 600;
color: #856404;
margin-bottom: 0.5rem;
display: block;
}
.response-screenshot {
margin-bottom: 1rem;
}
.response-screenshot img {
max-width: 100%;
border-radius: 4px;
border: 1px solid #ddd;
cursor: pointer;
transition: opacity 0.3s ease;
}
.response-screenshot img:hover {
opacity: 0.8;
}
.response-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 1rem;
border-top: 1px solid #e1e8ed;
}
.response-meta {
color: #7f8c8d;
font-size: 0.9rem;
}
.response-actions {
display: flex;
gap: 1rem;
}
.upvote-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border: 2px solid #3498db;
background: white;
color: #3498db;
border-radius: 20px;
cursor: pointer;
transition: all 0.3s ease;
font-weight: 600;
}
.upvote-btn:hover {
background: #3498db;
color: white;
transform: translateY(-2px);
}
.upvote-btn.upvoted {
background: #3498db;
color: white;
}
.upvote-btn .upvote-icon {
font-size: 1.2rem;
}
.upvote-count {
font-weight: bold;
}
/* Empty State */
.empty-state {
text-align: center;
padding: 3rem;
color: #7f8c8d;
}
.empty-state p {
font-size: 1.2rem;
margin-bottom: 1.5rem;
}
/* Modal Styles */
.modal {
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0, 0, 0, 0.5);
display: none;
}
.modal-content {
background-color: white;
margin: 5% auto;
padding: 2rem;
border-radius: 8px;
max-width: 600px;
position: relative;
}
.close {
color: #aaa;
float: right;
font-size: 28px;
font-weight: bold;
cursor: pointer;
}
.close:hover,
.close:focus {
color: #000;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: #1a202c;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
.form-group small {
display: block;
margin-top: 0.25rem;
color: #7f8c8d;
}
.form-actions {
display: flex;
gap: 1rem;
margin-top: 1.5rem;
}
.form-actions .btn {
flex: 1;
}
.loading {
text-align: center;
padding: 2rem;
color: #7f8c8d;
}
.load-more-container {
text-align: center;
margin: 2rem 0;
}
/* Responsive Design */
@media (max-width: 768px) {
.stats-banner {
flex-direction: column;
gap: 1.5rem;
}
.response-controls {
flex-direction: column;
align-items: stretch;
}
.filter-group {
flex-direction: column;
align-items: stretch;
}
#submit-response-btn {
margin-left: 0;
width: 100%;
}
.response-header {
flex-direction: column;
}
.response-badges {
margin-top: 1rem;
}
.response-footer {
flex-direction: column;
gap: 1rem;
align-items: flex-start;
}
.modal-content {
margin: 10% 5%;
padding: 1rem;
}
}

View File

@ -257,6 +257,10 @@ header p {
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
/* Prevent width changes when inline composer is added */
position: relative;
width: 100%;
box-sizing: border-box;
}
.rep-category h3 {
@ -358,6 +362,169 @@ header p {
border-color: #1e7e34;
}
/* Inline Email Composer Styles */
.inline-email-composer {
margin-top: 20px;
padding: 20px;
background: white;
border: 2px solid #005a9c;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
overflow: hidden;
max-height: 0;
opacity: 0;
transition: max-height 0.4s ease, opacity 0.3s ease, margin-top 0.4s ease, padding 0.4s ease;
/* Ensure it's not affected by any parent grid/flex container */
width: 100% !important;
max-width: none !important;
min-width: 0 !important;
grid-column: 1 / -1; /* Span full width if inside a grid */
display: block !important;
box-sizing: border-box !important;
}
.inline-email-composer.active {
max-height: 2000px;
opacity: 1;
margin-top: 20px;
padding: 20px;
}
.inline-email-composer.closing {
max-height: 0;
opacity: 0;
margin-top: 0;
padding: 0 20px;
}
.inline-email-composer .composer-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 2px solid #e9ecef;
}
.inline-email-composer .composer-header h3 {
color: #005a9c;
margin: 0;
font-size: 1.3em;
}
.inline-email-composer .close-btn {
font-size: 24px;
font-weight: bold;
cursor: pointer;
color: #999;
transition: color 0.3s ease;
background: none;
border: none;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
.inline-email-composer .close-btn:hover {
color: #333;
}
.inline-email-composer .composer-body {
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
transform: translateY(-20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.inline-email-composer .email-preview-details {
background-color: #f8f9fa;
padding: 15px;
border-radius: 6px;
border: 1px solid #e9ecef;
margin-bottom: 20px;
}
.inline-email-composer .detail-row {
margin-bottom: 8px;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.inline-email-composer .detail-row:last-child {
margin-bottom: 0;
}
.inline-email-composer .detail-row strong {
color: #495057;
min-width: 90px;
flex-shrink: 0;
}
.inline-email-composer .detail-row span {
color: #333;
word-break: break-word;
}
.inline-email-composer .email-preview-content {
background-color: white;
border: 1px solid #ddd;
border-radius: 6px;
padding: 20px;
max-height: 400px;
overflow-y: auto;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
}
/* Wrapper for sanitized email HTML to prevent it affecting parent page */
.inline-email-composer .email-preview-body {
max-width: 600px;
margin: 0 auto;
width: 100%;
box-sizing: border-box;
}
.inline-email-composer .email-preview-content p {
margin-bottom: 12px;
}
.inline-email-composer .email-preview-content p:last-child {
margin-bottom: 0;
}
/* Ensure all inline composer child elements respect full width */
.inline-email-composer .composer-header,
.inline-email-composer .composer-body,
.inline-email-composer .recipient-info,
.inline-email-composer .inline-email-form,
.inline-email-composer .form-group,
.inline-email-composer .form-actions,
.inline-email-composer .email-preview-details,
.inline-email-composer .email-preview-content {
width: 100%;
max-width: none;
box-sizing: border-box;
}
.inline-email-composer .form-group input,
.inline-email-composer .form-group textarea {
width: 100%;
max-width: none;
box-sizing: border-box;
}
/* Modal Styles */
.modal {
position: fixed;

View File

@ -109,6 +109,14 @@ class AdminPanel {
this.handleUserTypeChange(e.target.value);
});
}
// Response status filter
const responseStatusSelect = document.getElementById('admin-response-status');
if (responseStatusSelect) {
responseStatusSelect.addEventListener('change', () => {
this.loadAdminResponses();
});
}
}
setupFormInteractions() {
@ -407,6 +415,8 @@ class AdminPanel {
if (this.currentCampaign) {
this.populateEditForm();
}
} else if (tabName === 'responses') {
this.loadAdminResponses();
} else if (tabName === 'users') {
this.loadUsers();
}
@ -1051,6 +1061,231 @@ class AdminPanel {
this.showMessage('Failed to send emails: ' + error.message, 'error');
}
}
// Response Moderation Functions
async loadAdminResponses() {
const status = document.getElementById('admin-response-status').value;
const container = document.getElementById('admin-responses-container');
const loading = document.getElementById('responses-loading');
loading.classList.remove('hidden');
container.innerHTML = '';
try {
const params = new URLSearchParams({ status, limit: 100 });
const response = await window.apiClient.get(`/admin/responses?${params}`);
loading.classList.add('hidden');
if (response.success && response.responses.length > 0) {
this.renderAdminResponses(response.responses);
} else {
container.innerHTML = '<p style="text-align: center; color: #7f8c8d; padding: 2rem;">No responses found.</p>';
}
} catch (error) {
loading.classList.add('hidden');
console.error('Error loading responses:', error);
this.showMessage('Failed to load responses', 'error');
}
}
renderAdminResponses(responses) {
const container = document.getElementById('admin-responses-container');
console.log('Rendering admin responses:', responses.length, 'responses');
if (responses.length > 0) {
console.log('First response sample:', responses[0]);
}
container.innerHTML = responses.map(response => {
const createdDate = new Date(response.created_at).toLocaleString();
const statusClass = {
'pending': 'warning',
'approved': 'success',
'rejected': 'danger'
}[response.status] || 'secondary';
return `
<div class="response-admin-card" style="background: white; border: 1px solid #ddd; border-radius: 8px; padding: 1.5rem; margin-bottom: 1.5rem;">
<div style="display: flex; justify-content: space-between; margin-bottom: 1rem;">
<div>
<h4 style="margin: 0 0 0.5rem 0;">${this.escapeHtml(response.representative_name)}</h4>
<div style="color: #7f8c8d; font-size: 0.9rem;">
<span>${this.escapeHtml(response.representative_level)}</span>
<span>${this.escapeHtml(response.response_type)}</span>
<span>${createdDate}</span>
</div>
</div>
<div>
<span class="badge badge-${statusClass}" style="padding: 0.25rem 0.75rem; border-radius: 12px; font-size: 0.85rem;">
${response.status.toUpperCase()}
</span>
${response.is_verified ? '<span class="badge badge-success" style="padding: 0.25rem 0.75rem; border-radius: 12px; font-size: 0.85rem; margin-left: 0.5rem;">✓ VERIFIED</span>' : ''}
</div>
</div>
<div style="background: #f8f9fa; padding: 1rem; border-left: 4px solid #3498db; border-radius: 4px; margin-bottom: 1rem;">
<strong>Response:</strong>
<p style="margin: 0.5rem 0 0 0; white-space: pre-wrap;">${this.escapeHtml(response.response_text)}</p>
</div>
${response.user_comment ? `
<div style="background: #fff3cd; padding: 0.75rem; border-left: 4px solid #ffc107; border-radius: 4px; margin-bottom: 1rem;">
<strong>User Comment:</strong>
<p style="margin: 0.5rem 0 0 0;">${this.escapeHtml(response.user_comment)}</p>
</div>
` : ''}
${response.screenshot_url ? `
<div style="margin-bottom: 1rem;">
<img src="${this.escapeHtml(response.screenshot_url)}" style="max-width: 300px; border-radius: 4px; border: 1px solid #ddd;" alt="Screenshot">
</div>
` : ''}
<div style="color: #7f8c8d; font-size: 0.9rem; margin-bottom: 1rem;">
<strong>Submitted by:</strong> ${response.is_anonymous ? 'Anonymous' : (this.escapeHtml(response.submitted_by_name) || this.escapeHtml(response.submitted_by_email) || 'Unknown')}
<strong>Campaign:</strong> ${this.escapeHtml(response.campaign_slug)}
<strong>Upvotes:</strong> ${response.upvote_count || 0}
</div>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
${response.status === 'pending' ? `
<button class="btn btn-success btn-sm" data-action="approve-response" data-response-id="${response.id}"> Approve</button>
<button class="btn btn-danger btn-sm" data-action="reject-response" data-response-id="${response.id}"> Reject</button>
` : ''}
${response.status === 'approved' && !response.is_verified ? `
<button class="btn btn-primary btn-sm" data-action="verify-response" data-response-id="${response.id}" data-verified="true">Mark as Verified</button>
` : ''}
${response.status === 'approved' && response.is_verified ? `
<button class="btn btn-secondary btn-sm" data-action="verify-response" data-response-id="${response.id}" data-verified="false">Remove Verification</button>
` : ''}
${response.status === 'rejected' ? `
<button class="btn btn-success btn-sm" data-action="approve-response" data-response-id="${response.id}"> Approve</button>
` : ''}
${response.status === 'approved' ? `
<button class="btn btn-warning btn-sm" data-action="reject-response" data-response-id="${response.id}">Unpublish</button>
` : ''}
<button class="btn btn-danger btn-sm" data-action="delete-response" data-response-id="${response.id}">🗑 Delete</button>
</div>
</div>
`;
}).join('');
// Add event delegation for response actions
this.setupResponseActionListeners();
}
async approveResponse(id) {
await this.updateResponseStatus(id, 'approved');
}
async rejectResponse(id) {
await this.updateResponseStatus(id, 'rejected');
}
async updateResponseStatus(id, status) {
try {
const response = await window.apiClient.patch(`/admin/responses/${id}/status`, { status });
if (response.success) {
this.showMessage(`Response ${status} successfully!`, 'success');
this.loadAdminResponses();
} else {
throw new Error(response.error || 'Failed to update response status');
}
} catch (error) {
console.error('Error updating response status:', error);
this.showMessage('Failed to update response status: ' + error.message, 'error');
}
}
async toggleVerified(id, isVerified) {
try {
const response = await window.apiClient.patch(`/admin/responses/${id}`, {
is_verified: isVerified
});
if (response.success) {
this.showMessage(isVerified ? 'Response marked as verified!' : 'Verification removed!', 'success');
this.loadAdminResponses();
} else {
throw new Error(response.error || 'Failed to update verification status');
}
} catch (error) {
console.error('Error updating verification:', error);
this.showMessage('Failed to update verification: ' + error.message, 'error');
}
}
async deleteResponse(id) {
if (!confirm('Are you sure you want to delete this response? This action cannot be undone.')) return;
try {
const response = await window.apiClient.delete(`/admin/responses/${id}`);
if (response.success) {
this.showMessage('Response deleted successfully!', 'success');
this.loadAdminResponses();
} else {
throw new Error(response.error || 'Failed to delete response');
}
} catch (error) {
console.error('Error deleting response:', error);
this.showMessage('Failed to delete response: ' + error.message, 'error');
}
}
setupResponseActionListeners() {
const container = document.getElementById('admin-responses-container');
if (!container) return;
// Remove old listener if exists to avoid duplicates
const oldListener = container._responseActionListener;
if (oldListener) {
container.removeEventListener('click', oldListener);
}
// Create new listener
const listener = (e) => {
const target = e.target;
const action = target.dataset.action;
const responseId = target.dataset.responseId;
console.log('Response action clicked:', { action, responseId, target });
if (!action || !responseId) {
console.log('Missing action or responseId, ignoring click');
return;
}
switch (action) {
case 'approve-response':
this.approveResponse(parseInt(responseId));
break;
case 'reject-response':
this.rejectResponse(parseInt(responseId));
break;
case 'verify-response':
const isVerified = target.dataset.verified === 'true';
this.toggleVerified(parseInt(responseId), isVerified);
break;
case 'delete-response':
this.deleteResponse(parseInt(responseId));
break;
}
};
// Store listener reference and add it
container._responseActionListener = listener;
container.addEventListener('click', listener);
}
escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
// Initialize admin panel when DOM is loaded

View File

@ -45,6 +45,26 @@ class APIClient {
});
}
async put(endpoint, data) {
return this.makeRequest(endpoint, {
method: 'PUT',
body: JSON.stringify(data)
});
}
async patch(endpoint, data) {
return this.makeRequest(endpoint, {
method: 'PATCH',
body: JSON.stringify(data)
});
}
async delete(endpoint) {
return this.makeRequest(endpoint, {
method: 'DELETE'
});
}
async postFormData(endpoint, formData) {
// Don't set Content-Type header - browser will set it with boundary for multipart/form-data
const config = {

View File

@ -16,6 +16,8 @@ class EmailComposer {
this.currentRecipient = null;
this.currentEmailData = null;
this.lastPreviewTime = 0; // Track last preview request time
this.currentInlineComposer = null; // Track currently open inline composer
this.currentRepCard = null; // Track which rep card has the composer open
this.init();
}
@ -67,7 +69,533 @@ class EmailComposer {
});
}
openModal(recipient) {
openModal(recipient, repCard = null) {
// Close any existing inline composers first
this.closeInlineComposer();
this.currentRecipient = recipient;
this.currentRepCard = repCard;
// If repCard is provided, create inline composer
if (repCard) {
this.createInlineComposer(repCard, recipient);
return;
}
// Otherwise, use modal (fallback)
this.openModalDialog(recipient);
}
createInlineComposer(repCard, recipient) {
// Explicitly hide old modals
if (this.modal) this.modal.style.display = 'none';
if (this.previewModal) this.previewModal.style.display = 'none';
// Create the inline composer HTML
const composerHTML = this.getComposerHTML(recipient);
// Create a container div
const composerDiv = document.createElement('div');
composerDiv.className = 'inline-email-composer';
composerDiv.innerHTML = composerHTML;
// Find the parent category container (not the grid)
// The structure is: rep-category > rep-cards (grid) > rep-card
const repCardsGrid = repCard.parentElement; // The grid container
const repCategory = repCardsGrid.parentElement; // The category container
// Insert after the grid, not inside it
repCategory.insertAdjacentElement('beforeend', composerDiv);
this.currentInlineComposer = composerDiv;
// Trigger animation after a small delay
setTimeout(() => {
composerDiv.classList.add('active');
}, 10);
// Attach event listeners to the inline form
this.attachInlineFormListeners(composerDiv);
// Focus on first input
setTimeout(() => {
const firstInput = composerDiv.querySelector('#inline-sender-name');
if (firstInput) firstInput.focus();
}, 400);
// Scroll the composer into view
setTimeout(() => {
composerDiv.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, 100);
}
getComposerHTML(recipient) {
const postalCode = window.postalLookup ? window.postalLookup.currentPostalCode : '';
const defaultSubject = `Message from your constituent from ${postalCode}`;
return `
<div class="composer-header">
<h3>Compose Email to ${recipient.name}</h3>
<button class="close-btn inline-close" type="button">&times;</button>
</div>
<div class="composer-body">
<div class="recipient-info">
<strong>${recipient.name}</strong><br>
${recipient.office}<br>
${recipient.district}<br>
<em>${recipient.email}</em>
</div>
<form class="inline-email-form">
<input type="hidden" id="inline-recipient-email" value="${recipient.email}">
<div class="form-group">
<label for="inline-sender-name">Your Name *</label>
<input type="text" id="inline-sender-name" name="sender-name" required>
</div>
<div class="form-group">
<label for="inline-sender-email">Your Email *</label>
<input type="email" id="inline-sender-email" name="sender-email" required>
</div>
<div class="form-group">
<label for="inline-sender-postal-code">Your Postal Code *</label>
<input type="text"
id="inline-sender-postal-code"
name="sender-postal-code"
value="${postalCode}"
pattern="[A-Za-z]\\d[A-Za-z]\\s?\\d[A-Za-z]\\d"
placeholder="e.g., T5N 4B8"
required>
</div>
<div class="form-group">
<label for="inline-email-subject">Subject *</label>
<input type="text" id="inline-email-subject" name="email-subject" value="${defaultSubject}" required>
</div>
<div class="form-group">
<label for="inline-email-message">Your Message *</label>
<textarea id="inline-email-message" name="email-message" rows="8" maxlength="5000" required></textarea>
<span class="char-counter inline-char-counter">5000 characters remaining</span>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary inline-cancel">Cancel</button>
<button type="submit" class="btn btn-primary">Preview & Send</button>
</div>
</form>
</div>
`;
}
attachInlineFormListeners(composerDiv) {
// Close button
const closeBtn = composerDiv.querySelector('.inline-close');
closeBtn.addEventListener('click', () => this.closeInlineComposer());
// Cancel button
const cancelBtn = composerDiv.querySelector('.inline-cancel');
cancelBtn.addEventListener('click', () => this.closeInlineComposer());
// Form submission
const form = composerDiv.querySelector('.inline-email-form');
form.addEventListener('submit', (e) => this.handleInlinePreview(e, composerDiv));
// Character counter
const messageTextarea = composerDiv.querySelector('#inline-email-message');
const charCounter = composerDiv.querySelector('.inline-char-counter');
messageTextarea.addEventListener('input', () => {
const maxLength = 5000;
const currentLength = messageTextarea.value.length;
const remaining = maxLength - currentLength;
charCounter.textContent = `${remaining} characters remaining`;
if (remaining < 100) {
charCounter.style.color = '#d9534f';
} else {
charCounter.style.color = '#666';
}
});
// Update subject with sender name
const senderNameField = composerDiv.querySelector('#inline-sender-name');
const subjectField = composerDiv.querySelector('#inline-email-subject');
const postalCodeField = composerDiv.querySelector('#inline-sender-postal-code');
senderNameField.addEventListener('input', () => {
const senderName = senderNameField.value.trim();
const postalCode = postalCodeField.value.trim();
if (senderName && postalCode) {
subjectField.value = `Message from ${senderName} - ${postalCode}`;
}
});
}
closeInlineComposer() {
if (this.currentInlineComposer) {
// Add closing class for animation
this.currentInlineComposer.classList.remove('active');
this.currentInlineComposer.classList.add('closing');
// Remove from DOM after animation
setTimeout(() => {
if (this.currentInlineComposer && this.currentInlineComposer.parentNode) {
this.currentInlineComposer.parentNode.removeChild(this.currentInlineComposer);
}
this.currentInlineComposer = null;
this.currentRepCard = null;
}, 400);
}
}
async handleInlinePreview(e, composerDiv) {
e.preventDefault();
// Prevent duplicate calls
const currentTime = Date.now();
if (currentTime - this.lastPreviewTime < 2000) {
return;
}
this.lastPreviewTime = currentTime;
// Get form data from inline form
const formData = this.getInlineFormData(composerDiv);
const errors = this.validateInlineForm(formData);
if (errors.length > 0) {
window.messageDisplay.show(errors.join('<br>'), 'error');
return;
}
const submitButton = composerDiv.querySelector('button[type="submit"]');
const originalText = submitButton.textContent;
try {
submitButton.disabled = true;
submitButton.textContent = 'Loading preview...';
// Store form data for later use - match API expected field names
this.currentEmailData = {
recipientEmail: formData.recipientEmail,
senderName: formData.senderName,
senderEmail: formData.senderEmail,
postalCode: formData.postalCode, // API expects 'postalCode' not 'senderPostalCode'
subject: formData.subject,
message: formData.message
};
// Get preview from backend
const response = await window.apiClient.previewEmail(this.currentEmailData);
if (response.success) {
// Replace inline composer with inline preview
this.showInlinePreview(response.preview, composerDiv);
} else {
window.messageDisplay.show(response.error || 'Failed to generate preview', 'error');
}
} catch (error) {
console.error('Preview error:', error);
window.messageDisplay.show('Failed to generate email preview. Please try again.', 'error');
} finally {
submitButton.disabled = false;
submitButton.textContent = originalText;
}
}
getInlineFormData(composerDiv) {
return {
recipientEmail: composerDiv.querySelector('#inline-recipient-email').value.trim(),
senderName: composerDiv.querySelector('#inline-sender-name').value.trim(),
senderEmail: composerDiv.querySelector('#inline-sender-email').value.trim(),
postalCode: composerDiv.querySelector('#inline-sender-postal-code').value.trim(),
subject: composerDiv.querySelector('#inline-email-subject').value.trim(),
message: composerDiv.querySelector('#inline-email-message').value.trim()
};
}
validateInlineForm(formData) {
const errors = [];
if (!formData.senderName) {
errors.push('Please enter your name');
}
if (!formData.senderEmail) {
errors.push('Please enter your email');
} else if (!this.validateEmail(formData.senderEmail)) {
errors.push('Please enter a valid email address');
}
if (!formData.subject) {
errors.push('Please enter a subject');
}
if (!formData.message) {
errors.push('Please enter a message');
} else if (formData.message.length < 10) {
errors.push('Message must be at least 10 characters long');
}
// Validate postal code format if provided (required by API)
if (!formData.postalCode || formData.postalCode.trim() === '') {
errors.push('Postal code is required');
} else {
// Check postal code format: A1A 1A1 or A1A1A1
const postalCodePattern = /^[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d$/;
if (!postalCodePattern.test(formData.postalCode)) {
errors.push('Please enter a valid Canadian postal code (e.g., T5N 4B8)');
}
}
// Check for suspicious content
if (this.containsSuspiciousContent(formData.message) || this.containsSuspiciousContent(formData.subject)) {
errors.push('Message contains potentially malicious content');
}
return errors;
}
showInlinePreview(preview, composerDiv) {
// Ensure old preview modal is hidden
if (this.previewModal) {
this.previewModal.style.display = 'none';
}
// Replace the composer content with preview content
const previewHTML = this.getPreviewHTML(preview);
// Fade out composer body
const composerBody = composerDiv.querySelector('.composer-body');
composerBody.style.opacity = '0';
composerBody.style.transition = 'opacity 0.2s ease';
setTimeout(() => {
composerBody.innerHTML = previewHTML;
composerBody.style.opacity = '1';
// Attach event listeners to preview buttons
this.attachPreviewListeners(composerDiv);
// Update header
const header = composerDiv.querySelector('.composer-header h3');
header.textContent = 'Email Preview';
// Scroll to make sure preview is visible
setTimeout(() => {
composerDiv.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, 100);
}, 200);
}
getPreviewHTML(preview) {
const html = preview.html || '';
const testModeWarning = preview.testMode ?
`<div style="background: #fff3cd; color: #856404; padding: 10px; border-radius: 4px; margin-bottom: 15px;">
<strong>TEST MODE:</strong> Email will be redirected to ${this.escapeHtml(preview.redirectTo)}
</div>` : '';
// Sanitize the HTML to remove any <style> tags or <body> styles that would affect the page
const sanitizedHtml = this.sanitizeEmailHTML(html);
return `
${testModeWarning}
<div class="email-preview-details">
<div class="detail-row">
<strong>From:</strong> <span>${this.escapeHtml(preview.from)}</span>
</div>
<div class="detail-row">
<strong>To:</strong> <span>${this.escapeHtml(preview.to)}</span>
</div>
<div class="detail-row">
<strong>Subject:</strong> <span>${this.escapeHtml(preview.subject)}</span>
</div>
<div class="detail-row">
<strong>Timestamp:</strong> <span>${new Date(preview.timestamp).toLocaleString()}</span>
</div>
</div>
<div style="margin: 20px 0;">
<strong style="display: block; margin-bottom: 10px; color: #005a9c;">Message Preview:</strong>
<div class="email-preview-content">
${sanitizedHtml}
</div>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary preview-edit"> Edit Email</button>
<button type="button" class="btn btn-primary preview-send">Send Email </button>
</div>
`;
}
sanitizeEmailHTML(html) {
// Remove DOCTYPE, html, head, body tags and their attributes
// These can interfere with the parent page's styling
let sanitized = html
.replace(/<!DOCTYPE[^>]*>/gi, '')
.replace(/<html[^>]*>/gi, '')
.replace(/<\/html>/gi, '')
.replace(/<head[^>]*>[\s\S]*?<\/head>/gi, '')
.replace(/<body[^>]*>/gi, '<div>')
.replace(/<\/body>/gi, '</div>');
// Remove or modify style tags that could affect the parent page
// Wrap them in a scoped container instead of removing entirely
sanitized = sanitized.replace(/<style[^>]*>([\s\S]*?)<\/style>/gi, (match, styleContent) => {
// Remove any 'body' selectors and replace with a safe class
const scopedStyles = styleContent
.replace(/\bbody\s*{/gi, '.email-preview-body {')
.replace(/\bhtml\s*{/gi, '.email-preview-body {');
return `<style>${scopedStyles}</style>`;
});
// Wrap the sanitized content in a container div
return `<div class="email-preview-body">${sanitized}</div>`;
}
attachPreviewListeners(composerDiv) {
// Edit button - go back to composer
const editBtn = composerDiv.querySelector('.preview-edit');
editBtn.addEventListener('click', () => {
this.returnToComposer(composerDiv);
});
// Send button - send the email
const sendBtn = composerDiv.querySelector('.preview-send');
sendBtn.addEventListener('click', async () => {
await this.sendFromInlinePreview(sendBtn, composerDiv);
});
}
returnToComposer(composerDiv) {
// Restore the composer form with the current data
const composerFormHTML = this.getComposerFormHTML();
const composerBody = composerDiv.querySelector('.composer-body');
composerBody.style.opacity = '0';
setTimeout(() => {
composerBody.innerHTML = composerFormHTML;
// Re-populate form fields with saved data
if (this.currentEmailData) {
composerDiv.querySelector('#inline-sender-name').value = this.currentEmailData.senderName || '';
composerDiv.querySelector('#inline-sender-email').value = this.currentEmailData.senderEmail || '';
composerDiv.querySelector('#inline-sender-postal-code').value = this.currentEmailData.postalCode || '';
composerDiv.querySelector('#inline-email-subject').value = this.currentEmailData.subject || '';
composerDiv.querySelector('#inline-email-message').value = this.currentEmailData.message || '';
// Update character counter
const messageField = composerDiv.querySelector('#inline-email-message');
const charCounter = composerDiv.querySelector('.inline-char-counter');
const remaining = 5000 - messageField.value.length;
charCounter.textContent = `${remaining} characters remaining`;
}
composerBody.style.opacity = '1';
// Re-attach form listeners
this.attachInlineFormListeners(composerDiv);
// Update header
const header = composerDiv.querySelector('.composer-header h3');
header.textContent = `Compose Email to ${this.currentRecipient.name}`;
}, 200);
}
getComposerFormHTML() {
const recipient = this.currentRecipient;
return `
<div class="recipient-info">
<strong>${recipient.name}</strong><br>
${recipient.office}<br>
${recipient.district}<br>
<em>${recipient.email}</em>
</div>
<form class="inline-email-form">
<input type="hidden" id="inline-recipient-email" value="${recipient.email}">
<div class="form-group">
<label for="inline-sender-name">Your Name *</label>
<input type="text" id="inline-sender-name" name="sender-name" required>
</div>
<div class="form-group">
<label for="inline-sender-email">Your Email *</label>
<input type="email" id="inline-sender-email" name="sender-email" required>
</div>
<div class="form-group">
<label for="inline-sender-postal-code">Your Postal Code *</label>
<input type="text"
id="inline-sender-postal-code"
name="sender-postal-code"
pattern="[A-Za-z]\\d[A-Za-z]\\s?\\d[A-Za-z]\\d"
placeholder="e.g., T5N 4B8"
required>
</div>
<div class="form-group">
<label for="inline-email-subject">Subject *</label>
<input type="text" id="inline-email-subject" name="email-subject" required>
</div>
<div class="form-group">
<label for="inline-email-message">Your Message *</label>
<textarea id="inline-email-message" name="email-message" rows="8" maxlength="5000" required></textarea>
<span class="char-counter inline-char-counter">5000 characters remaining</span>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary inline-cancel">Cancel</button>
<button type="submit" class="btn btn-primary">Preview & Send</button>
</div>
</form>
`;
}
async sendFromInlinePreview(sendBtn, composerDiv) {
if (!this.currentEmailData) {
window.messageDisplay.show('No email data to send', 'error');
return;
}
const originalText = sendBtn.textContent;
try {
sendBtn.disabled = true;
sendBtn.textContent = 'Sending...';
const result = await window.apiClient.sendEmail(this.currentEmailData);
if (result.success) {
window.messageDisplay.show('Email sent successfully! Your representative will receive your message.', 'success', 7000);
// Close the inline composer after successful send
this.closeInlineComposer();
this.currentEmailData = null;
this.currentRecipient = null;
} else {
throw new Error(result.message || 'Failed to send email');
}
} catch (error) {
console.error('Email send failed:', error);
// Handle rate limit errors specifically
if (error.message && error.message.includes('rate limit')) {
window.messageDisplay.show('You are sending emails too quickly. Please wait a few minutes and try again.', 'error', 8000);
} else {
window.messageDisplay.show(`Failed to send email: ${error.message}`, 'error', 6000);
}
} finally {
sendBtn.disabled = false;
sendBtn.textContent = originalText;
}
}
openModalDialog(recipient) {
this.currentRecipient = recipient;
// Populate recipient info

View File

@ -354,43 +354,49 @@ class RepresentativesDisplay {
}
attachEventListeners() {
// Add event listeners for compose email buttons
// Email compose buttons
const composeButtons = this.container.querySelectorAll('.compose-email');
composeButtons.forEach(button => {
button.addEventListener('click', (e) => {
const email = e.target.dataset.email;
const name = e.target.dataset.name;
const office = e.target.dataset.office;
const district = e.target.dataset.district;
e.preventDefault();
const email = button.dataset.email;
const name = button.dataset.name;
const office = button.dataset.office;
const district = button.dataset.district;
// Find the closest rep-card ancestor
const repCard = button.closest('.rep-card');
if (window.emailComposer) {
window.emailComposer.openModal({
email,
name,
office,
district
});
}, repCard); // Pass the card element
}
});
});
// Add event listeners for call buttons
// Call buttons
const callButtons = this.container.querySelectorAll('.call-representative');
callButtons.forEach(button => {
button.addEventListener('click', (e) => {
const phone = e.target.dataset.phone;
const name = e.target.dataset.name;
const office = e.target.dataset.office;
const officeType = e.target.dataset.officeType;
e.preventDefault();
const phone = button.dataset.phone;
const name = button.dataset.name;
const office = button.dataset.office;
const officeType = button.dataset.officeType || '';
this.handleCallClick(phone, name, office, officeType);
});
});
// Add event listeners for visit buttons
// Visit buttons (for office addresses)
const visitButtons = this.container.querySelectorAll('.visit-office');
visitButtons.forEach(button => {
button.addEventListener('click', (e) => {
e.preventDefault();
// Use currentTarget to ensure we get the button, not nested elements
const address = button.dataset.address;
const name = button.dataset.name;
const office = button.dataset.office;
@ -399,14 +405,13 @@ class RepresentativesDisplay {
});
});
// Add event listeners for image error handling
const repImages = this.container.querySelectorAll('.rep-photo img');
repImages.forEach(img => {
img.addEventListener('error', (e) => {
// Hide the image and show the fallback
e.target.style.display = 'none';
const fallback = e.target.nextElementSibling;
if (fallback && fallback.classList.contains('rep-photo-fallback')) {
// Photo error handling (fallback to initials)
const photos = this.container.querySelectorAll('.rep-photo img');
photos.forEach(img => {
img.addEventListener('error', function() {
this.style.display = 'none';
const fallback = this.parentElement.querySelector('.rep-photo-fallback');
if (fallback) {
fallback.style.display = 'flex';
}
});

View File

@ -0,0 +1,365 @@
// Response Wall JavaScript
let currentCampaignSlug = null;
let currentOffset = 0;
let currentSort = 'recent';
let currentLevel = '';
const LIMIT = 20;
// Initialize
document.addEventListener('DOMContentLoaded', () => {
console.log('Response Wall: Initializing...');
// Get campaign slug from URL if present
const urlParams = new URLSearchParams(window.location.search);
currentCampaignSlug = urlParams.get('campaign');
console.log('Campaign slug:', currentCampaignSlug);
if (!currentCampaignSlug) {
showError('No campaign specified');
return;
}
// Load initial data
loadResponseStats();
loadResponses(true);
// Set up event listeners
document.getElementById('sort-select').addEventListener('change', (e) => {
currentSort = e.target.value;
loadResponses(true);
});
document.getElementById('level-filter').addEventListener('change', (e) => {
currentLevel = e.target.value;
loadResponses(true);
});
const submitBtn = document.getElementById('submit-response-btn');
console.log('Submit button found:', !!submitBtn);
if (submitBtn) {
submitBtn.addEventListener('click', () => {
console.log('Submit button clicked');
openSubmitModal();
});
}
// Use event delegation for empty state button since it's dynamically shown
document.addEventListener('click', (e) => {
if (e.target.id === 'empty-state-submit-btn') {
console.log('Empty state button clicked');
openSubmitModal();
}
});
const modalCloseBtn = document.getElementById('modal-close-btn');
if (modalCloseBtn) {
modalCloseBtn.addEventListener('click', closeSubmitModal);
}
const cancelBtn = document.getElementById('cancel-submit-btn');
if (cancelBtn) {
cancelBtn.addEventListener('click', closeSubmitModal);
}
const loadMoreBtn = document.getElementById('load-more-btn');
if (loadMoreBtn) {
loadMoreBtn.addEventListener('click', loadMoreResponses);
}
const form = document.getElementById('submit-response-form');
if (form) {
form.addEventListener('submit', handleSubmitResponse);
}
console.log('Response Wall: Initialization complete');
});
// Load response statistics
async function loadResponseStats() {
try {
const response = await fetch(`/api/campaigns/${currentCampaignSlug}/response-stats`);
const data = await response.json();
if (data.success) {
document.getElementById('stat-total-responses').textContent = data.stats.totalResponses;
document.getElementById('stat-verified').textContent = data.stats.verifiedResponses;
document.getElementById('stat-upvotes').textContent = data.stats.totalUpvotes;
document.getElementById('stats-banner').style.display = 'flex';
}
} catch (error) {
console.error('Error loading stats:', error);
}
}
// Load responses
async function loadResponses(reset = false) {
if (reset) {
currentOffset = 0;
document.getElementById('responses-container').innerHTML = '';
}
showLoading(true);
try {
const params = new URLSearchParams({
sort: currentSort,
level: currentLevel,
offset: currentOffset,
limit: LIMIT
});
const response = await fetch(`/api/campaigns/${currentCampaignSlug}/responses?${params}`);
const data = await response.json();
showLoading(false);
if (data.success && data.responses.length > 0) {
renderResponses(data.responses);
// Show/hide load more button
if (data.pagination.hasMore) {
document.getElementById('load-more-container').style.display = 'block';
} else {
document.getElementById('load-more-container').style.display = 'none';
}
} else if (reset) {
showEmptyState();
}
} catch (error) {
showLoading(false);
showError('Failed to load responses');
console.error('Error loading responses:', error);
}
}
// Render responses
function renderResponses(responses) {
const container = document.getElementById('responses-container');
responses.forEach(response => {
const card = createResponseCard(response);
container.appendChild(card);
});
}
// Create response card element
function createResponseCard(response) {
const card = document.createElement('div');
card.className = 'response-card';
card.dataset.responseId = response.id;
const createdDate = new Date(response.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
let badges = `<span class="badge badge-level">${escapeHtml(response.representative_level)}</span>`;
badges += `<span class="badge badge-type">${escapeHtml(response.response_type)}</span>`;
if (response.is_verified) {
badges = `<span class="badge badge-verified">✓ Verified</span>` + badges;
}
let submittedBy = 'Anonymous';
if (!response.is_anonymous && response.submitted_by_name) {
submittedBy = escapeHtml(response.submitted_by_name);
}
let userCommentHtml = '';
if (response.user_comment) {
userCommentHtml = `
<div class="user-comment">
<span class="user-comment-label">Constituent's Comment:</span>
${escapeHtml(response.user_comment)}
</div>
`;
}
let screenshotHtml = '';
if (response.screenshot_url) {
screenshotHtml = `
<div class="response-screenshot">
<img src="${escapeHtml(response.screenshot_url)}"
alt="Response screenshot"
data-image-url="${escapeHtml(response.screenshot_url)}"
class="screenshot-image">
</div>
`;
}
const upvoteClass = response.hasUpvoted ? 'upvoted' : '';
card.innerHTML = `
<div class="response-header">
<div class="response-rep-info">
<h3>${escapeHtml(response.representative_name)}</h3>
<div class="rep-meta">
${response.representative_title ? `<span>${escapeHtml(response.representative_title)}</span>` : ''}
<span>${createdDate}</span>
</div>
</div>
<div class="response-badges">
${badges}
</div>
</div>
<div class="response-content">
<div class="response-text">${escapeHtml(response.response_text)}</div>
${userCommentHtml}
${screenshotHtml}
</div>
<div class="response-footer">
<div class="response-meta">
Submitted by ${submittedBy}
</div>
<div class="response-actions">
<button class="upvote-btn ${upvoteClass}" data-response-id="${response.id}">
<span class="upvote-icon">👍</span>
<span class="upvote-count">${response.upvote_count || 0}</span>
</button>
</div>
</div>
`;
// Add event listener for upvote button
const upvoteBtn = card.querySelector('.upvote-btn');
upvoteBtn.addEventListener('click', function() {
toggleUpvote(response.id, this);
});
// Add event listener for screenshot image if present
const screenshotImg = card.querySelector('.screenshot-image');
if (screenshotImg) {
screenshotImg.addEventListener('click', function() {
viewImage(this.dataset.imageUrl);
});
}
return card;
}
// Toggle upvote
async function toggleUpvote(responseId, button) {
const isUpvoted = button.classList.contains('upvoted');
const url = `/api/responses/${responseId}/upvote`;
try {
const response = await fetch(url, {
method: isUpvoted ? 'DELETE' : 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const data = await response.json();
if (data.success) {
// Update button state
button.classList.toggle('upvoted');
button.querySelector('.upvote-count').textContent = data.upvoteCount;
// Reload stats
loadResponseStats();
} else {
showError(data.error || 'Failed to update upvote');
}
} catch (error) {
console.error('Error toggling upvote:', error);
showError('Failed to update upvote');
}
}
// Load more responses
function loadMoreResponses() {
currentOffset += LIMIT;
loadResponses(false);
}
// Open submit modal
function openSubmitModal() {
console.log('openSubmitModal called');
const modal = document.getElementById('submit-modal');
if (modal) {
modal.style.display = 'block';
console.log('Modal displayed');
} else {
console.error('Modal element not found');
}
}
// Close submit modal
function closeSubmitModal() {
document.getElementById('submit-modal').style.display = 'none';
document.getElementById('submit-response-form').reset();
}
// Handle response submission
async function handleSubmitResponse(e) {
e.preventDefault();
const formData = new FormData(e.target);
try {
const response = await fetch(`/api/campaigns/${currentCampaignSlug}/responses`, {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.success) {
showSuccess(data.message || 'Response submitted successfully! It will appear after moderation.');
closeSubmitModal();
// Don't reload responses since submission is pending approval
} else {
showError(data.error || 'Failed to submit response');
}
} catch (error) {
console.error('Error submitting response:', error);
showError('Failed to submit response');
}
}
// View image in modal/new tab
function viewImage(url) {
window.open(url, '_blank');
}
// Utility functions
function showLoading(show) {
document.getElementById('loading').style.display = show ? 'block' : 'none';
}
function showEmptyState() {
document.getElementById('empty-state').style.display = 'block';
document.getElementById('responses-container').innerHTML = '';
document.getElementById('load-more-container').style.display = 'none';
}
function showError(message) {
// Simple alert for now - could integrate with existing error display system
alert('Error: ' + message);
}
function showSuccess(message) {
// Simple alert for now - could integrate with existing success display system
alert(message);
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Close modal when clicking outside
window.onclick = function(event) {
const modal = document.getElementById('submit-modal');
if (event.target === modal) {
closeSubmitModal();
}
};

View File

@ -0,0 +1,163 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Response Wall | 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 representatives are saying back to constituents</p>
</header>
<!-- Stats Banner -->
<div class="stats-banner" id="stats-banner" style="display: none;">
<div class="stat-item">
<span class="stat-number" id="stat-total-responses">0</span>
<span class="stat-label">Total Responses</span>
</div>
<div class="stat-item">
<span class="stat-number" id="stat-verified">0</span>
<span class="stat-label">Verified</span>
</div>
<div class="stat-item">
<span class="stat-number" id="stat-upvotes">0</span>
<span class="stat-label">Total Upvotes</span>
</div>
</div>
<!-- Controls -->
<div class="response-controls">
<div class="filter-group">
<label for="sort-select">Sort by:</label>
<select id="sort-select">
<option value="recent">Most Recent</option>
<option value="upvotes">Most Upvoted</option>
<option value="verified">Verified First</option>
</select>
</div>
<div class="filter-group">
<label for="level-filter">Filter by 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>
<option value="School Board">School Board</option>
</select>
</div>
<button class="btn btn-primary" id="submit-response-btn">
✍️ Share a Response
</button>
</div>
<!-- Loading Indicator -->
<div id="loading" class="loading" style="display: none;">
<p>Loading responses...</p>
</div>
<!-- Empty State -->
<div id="empty-state" class="empty-state" style="display: none;">
<p>No responses yet. Be the first to share!</p>
<button class="btn btn-primary" id="empty-state-submit-btn">Share a Response</button>
</div>
<!-- Responses Container -->
<div id="responses-container"></div>
<!-- Load More Button -->
<div class="load-more-container" id="load-more-container" style="display: none;">
<button class="btn btn-secondary" id="load-more-btn">Load More</button>
</div>
</div>
<!-- Submit Response Modal -->
<div id="submit-modal" class="modal">
<div class="modal-content">
<span class="close" id="modal-close-btn">&times;</span>
<h2>Share a Representative Response</h2>
<form id="submit-response-form" enctype="multipart/form-data">
<div class="form-group">
<label for="representative-name">Representative Name *</label>
<input type="text" id="representative-name" name="representative_name" required>
</div>
<div class="form-group">
<label for="representative-title">Representative Title</label>
<input type="text" id="representative-title" name="representative_title" placeholder="e.g., MLA, MP, Councillor">
</div>
<div class="form-group">
<label for="representative-level">Government Level *</label>
<select id="representative-level" name="representative_level" required>
<option value="">Select level...</option>
<option value="Federal">Federal</option>
<option value="Provincial">Provincial</option>
<option value="Municipal">Municipal</option>
<option value="School Board">School Board</option>
</select>
</div>
<div class="form-group">
<label for="response-type">Response Type *</label>
<select id="response-type" name="response_type" required>
<option value="">Select type...</option>
<option value="Email">Email</option>
<option value="Letter">Letter</option>
<option value="Phone Call">Phone Call</option>
<option value="Meeting">Meeting</option>
<option value="Social Media">Social Media</option>
<option value="Other">Other</option>
</select>
</div>
<div class="form-group">
<label for="response-text">Response Text *</label>
<textarea id="response-text" name="response_text" rows="6" required placeholder="Paste or type the response you received..."></textarea>
</div>
<div class="form-group">
<label for="user-comment">Your Comment (Optional)</label>
<textarea id="user-comment" name="user_comment" rows="3" placeholder="Add your thoughts about this response..."></textarea>
</div>
<div class="form-group">
<label for="screenshot">Screenshot (Optional)</label>
<input type="file" id="screenshot" name="screenshot" accept="image/*">
<small>Upload a screenshot of the response (max 5MB)</small>
</div>
<div class="form-group">
<label for="submitted-by-name">Your Name (Optional)</label>
<input type="text" id="submitted-by-name" name="submitted_by_name">
</div>
<div class="form-group">
<label for="submitted-by-email">Your Email (Optional)</label>
<input type="email" id="submitted-by-email" name="submitted_by_email">
</div>
<div class="form-group">
<label>
<input type="checkbox" id="is-anonymous" name="is_anonymous">
Post anonymously (don't show my name)
</label>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="cancel-submit-btn">Cancel</button>
<button type="submit" class="btn btn-primary">Submit Response</button>
</div>
</form>
</div>
</div>
<script src="/js/response-wall.js"></script>
</body>
</html>

View File

@ -4,8 +4,10 @@ const { body, param, validationResult } = require('express-validator');
const representativesController = require('../controllers/representatives');
const emailsController = require('../controllers/emails');
const campaignsController = require('../controllers/campaigns');
const responsesController = require('../controllers/responses');
const rateLimiter = require('../utils/rate-limiter');
const { requireAdmin, requireAuth, requireNonTemp } = require('../middleware/auth');
const { requireAdmin, requireAuth, requireNonTemp, optionalAuth } = require('../middleware/auth');
const upload = require('../middleware/upload');
// Import user routes
const userRoutes = require('./users');
@ -192,4 +194,51 @@ router.post(
// User management routes (admin only)
router.use('/admin/users', userRoutes);
// Response Wall Routes
router.get('/campaigns/:slug/responses', rateLimiter.general, responsesController.getCampaignResponses);
router.get('/campaigns/:slug/response-stats', rateLimiter.general, responsesController.getResponseStats);
router.post(
'/campaigns/:slug/responses',
optionalAuth,
upload.single('screenshot'),
rateLimiter.general,
[
body('representative_name').notEmpty().withMessage('Representative name is required'),
body('representative_level').isIn(['Federal', 'Provincial', 'Municipal', 'School Board']).withMessage('Invalid representative level'),
body('response_type').isIn(['Email', 'Letter', 'Phone Call', 'Meeting', 'Social Media', 'Other']).withMessage('Invalid response type'),
body('response_text').notEmpty().withMessage('Response text is required')
],
handleValidationErrors,
responsesController.submitResponse
);
router.post('/responses/:id/upvote', optionalAuth, rateLimiter.general, responsesController.upvoteResponse);
router.delete('/responses/:id/upvote', optionalAuth, rateLimiter.general, responsesController.removeUpvote);
// Admin Response Management Routes
router.get('/admin/responses', requireAdmin, rateLimiter.general, responsesController.getAdminResponses);
router.patch('/admin/responses/:id/status', requireAdmin, rateLimiter.general,
[body('status').isIn(['pending', 'approved', 'rejected']).withMessage('Invalid status')],
handleValidationErrors,
responsesController.updateResponseStatus
);
router.patch('/admin/responses/:id', requireAdmin, rateLimiter.general, responsesController.updateResponse);
router.delete('/admin/responses/:id', requireAdmin, rateLimiter.general, responsesController.deleteResponse);
// Debug endpoint to check raw NocoDB data
router.get('/debug/responses', requireAdmin, async (req, res) => {
try {
const nocodbService = require('../services/nocodb');
// Get raw data without normalization
const rawResult = await nocodbService.getAll(nocodbService.tableIds.representativeResponses, {});
res.json({
success: true,
count: rawResult.list?.length || 0,
rawResponses: rawResult.list || [],
normalized: await nocodbService.getRepresentativeResponses({})
});
} catch (error) {
res.status(500).json({ error: error.message, stack: error.stack });
}
});
module.exports = router;

View File

@ -26,7 +26,9 @@ class NocoDBService {
campaigns: process.env.NOCODB_TABLE_CAMPAIGNS,
campaignEmails: process.env.NOCODB_TABLE_CAMPAIGN_EMAILS,
users: process.env.NOCODB_TABLE_USERS,
calls: process.env.NOCODB_TABLE_CALLS
calls: process.env.NOCODB_TABLE_CALLS,
representativeResponses: process.env.NOCODB_TABLE_REPRESENTATIVE_RESPONSES,
responseUpvotes: process.env.NOCODB_TABLE_RESPONSE_UPVOTES
};
// Validate that all table IDs are set
@ -688,6 +690,191 @@ class NocoDBService {
return await this.getAll(this.tableIds.users, params);
}
// Representative Responses methods
async getRepresentativeResponses(params = {}) {
if (!this.tableIds.representativeResponses) {
throw new Error('Representative responses table not configured');
}
console.log('getRepresentativeResponses params:', JSON.stringify(params, null, 2));
const result = await this.getAll(this.tableIds.representativeResponses, params);
// Log without the where clause to see ALL responses
if (params.where) {
const allResult = await this.getAll(this.tableIds.representativeResponses, {});
console.log(`Total responses in DB (no filter): ${allResult.list?.length || 0}`);
if (allResult.list && allResult.list.length > 0) {
console.log('Sample raw response from DB:', JSON.stringify(allResult.list[0], null, 2));
}
}
console.log('getRepresentativeResponses raw result:', JSON.stringify(result, null, 2));
// NocoDB returns {list: [...]} or {pageInfo: {...}, list: [...]}
const list = result.list || [];
console.log(`getRepresentativeResponses: Found ${list.length} responses`);
return list.map(item => this.normalizeResponse(item));
}
async getRepresentativeResponseById(responseId) {
if (!this.tableIds.representativeResponses) {
throw new Error('Representative responses table not configured');
}
try {
const url = `${this.getTableUrl(this.tableIds.representativeResponses)}/${responseId}`;
const response = await this.client.get(url);
return this.normalizeResponse(response.data);
} catch (error) {
console.error('Error getting representative response by ID:', error);
throw error;
}
}
async createRepresentativeResponse(responseData) {
if (!this.tableIds.representativeResponses) {
throw new Error('Representative responses table not configured');
}
// Ensure campaign_id is not null/undefined
if (!responseData.campaign_id) {
throw new Error('Campaign ID is required for creating a response');
}
const data = {
'Campaign ID': responseData.campaign_id,
'Campaign Slug': responseData.campaign_slug,
'Representative Name': responseData.representative_name,
'Representative Title': responseData.representative_title,
'Representative Level': responseData.representative_level,
'Response Type': responseData.response_type,
'Response Text': responseData.response_text,
'User Comment': responseData.user_comment,
'Screenshot URL': responseData.screenshot_url,
'Submitted By Name': responseData.submitted_by_name,
'Submitted By Email': responseData.submitted_by_email,
'Submitted By User ID': responseData.submitted_by_user_id,
'Is Anonymous': responseData.is_anonymous,
'Status': responseData.status,
'Is Verified': responseData.is_verified,
'Upvote Count': responseData.upvote_count,
'Submitted IP': responseData.submitted_ip
};
console.log('Creating response with data:', JSON.stringify(data, null, 2));
const url = this.getTableUrl(this.tableIds.representativeResponses);
const response = await this.client.post(url, data);
return this.normalizeResponse(response.data);
}
async updateRepresentativeResponse(responseId, updates) {
if (!this.tableIds.representativeResponses) {
throw new Error('Representative responses table not configured');
}
const data = {};
if (updates.status !== undefined) data['Status'] = updates.status;
if (updates.is_verified !== undefined) data['Is Verified'] = updates.is_verified;
if (updates.upvote_count !== undefined) data['Upvote Count'] = updates.upvote_count;
if (updates.response_text !== undefined) data['Response Text'] = updates.response_text;
if (updates.user_comment !== undefined) data['User Comment'] = updates.user_comment;
console.log(`Updating response ${responseId} with data:`, JSON.stringify(data, null, 2));
const url = `${this.getTableUrl(this.tableIds.representativeResponses)}/${responseId}`;
const response = await this.client.patch(url, data);
console.log('NocoDB update response:', JSON.stringify(response.data, null, 2));
return this.normalizeResponse(response.data);
}
async deleteRepresentativeResponse(responseId) {
if (!this.tableIds.representativeResponses) {
throw new Error('Representative responses table not configured');
}
const url = `${this.getTableUrl(this.tableIds.representativeResponses)}/${responseId}`;
const response = await this.client.delete(url);
return response.data;
}
// Response Upvotes methods
async getResponseUpvotes(params = {}) {
if (!this.tableIds.responseUpvotes) {
throw new Error('Response upvotes table not configured');
}
const result = await this.getAll(this.tableIds.responseUpvotes, params);
// NocoDB returns {list: [...]} or {pageInfo: {...}, list: [...]}
const list = result.list || [];
return list.map(item => this.normalizeUpvote(item));
}
async createResponseUpvote(upvoteData) {
if (!this.tableIds.responseUpvotes) {
throw new Error('Response upvotes table not configured');
}
const data = {
'Response ID': upvoteData.response_id,
'User ID': upvoteData.user_id,
'User Email': upvoteData.user_email,
'Upvoted IP': upvoteData.upvoted_ip
};
const url = this.getTableUrl(this.tableIds.responseUpvotes);
const response = await this.client.post(url, data);
return this.normalizeUpvote(response.data);
}
async deleteResponseUpvote(upvoteId) {
if (!this.tableIds.responseUpvotes) {
throw new Error('Response upvotes table not configured');
}
const url = `${this.getTableUrl(this.tableIds.responseUpvotes)}/${upvoteId}`;
const response = await this.client.delete(url);
return response.data;
}
// Normalize response data from NocoDB format to application format
normalizeResponse(data) {
if (!data) return null;
return {
id: data.ID || data.Id || data.id,
campaign_id: data['Campaign ID'] || data.campaign_id,
campaign_slug: data['Campaign Slug'] || data.campaign_slug,
representative_name: data['Representative Name'] || data.representative_name,
representative_title: data['Representative Title'] || data.representative_title,
representative_level: data['Representative Level'] || data.representative_level,
response_type: data['Response Type'] || data.response_type,
response_text: data['Response Text'] || data.response_text,
user_comment: data['User Comment'] || data.user_comment,
screenshot_url: data['Screenshot URL'] || data.screenshot_url,
submitted_by_name: data['Submitted By Name'] || data.submitted_by_name,
submitted_by_email: data['Submitted By Email'] || data.submitted_by_email,
submitted_by_user_id: data['Submitted By User ID'] || data.submitted_by_user_id,
is_anonymous: data['Is Anonymous'] || data.is_anonymous || false,
status: data['Status'] || data.status,
is_verified: data['Is Verified'] || data.is_verified || false,
upvote_count: data['Upvote Count'] || data.upvote_count || 0,
submitted_ip: data['Submitted IP'] || data.submitted_ip,
created_at: data.CreatedAt || data.created_at,
updated_at: data.UpdatedAt || data.updated_at
};
}
// Normalize upvote data from NocoDB format to application format
normalizeUpvote(data) {
if (!data) return null;
return {
id: data.ID || data.Id || data.id,
response_id: data['Response ID'] || data.response_id,
user_id: data['User ID'] || data.user_id,
user_email: data['User Email'] || data.user_email,
upvoted_ip: data['Upvoted IP'] || data.upvoted_ip,
created_at: data.CreatedAt || data.created_at
};
}
}
module.exports = new NocoDBService();

View File

@ -78,6 +78,52 @@ function validateSlug(slug) {
return slugPattern.test(slug) && slug.length >= 3 && slug.length <= 100;
}
// Validate response submission
function validateResponse(data) {
const { representative_name, representative_level, response_type, response_text } = data;
// Check required fields
if (!representative_name || representative_name.trim() === '') {
return { valid: false, error: 'Representative name is required' };
}
if (!representative_level || representative_level.trim() === '') {
return { valid: false, error: 'Representative level is required' };
}
// Validate representative level
const validLevels = ['Federal', 'Provincial', 'Municipal', 'School Board'];
if (!validLevels.includes(representative_level)) {
return { valid: false, error: 'Invalid representative level' };
}
if (!response_type || response_type.trim() === '') {
return { valid: false, error: 'Response type is required' };
}
// Validate response type
const validTypes = ['Email', 'Letter', 'Phone Call', 'Meeting', 'Social Media', 'Other'];
if (!validTypes.includes(response_type)) {
return { valid: false, error: 'Invalid response type' };
}
if (!response_text || response_text.trim() === '') {
return { valid: false, error: 'Response text is required' };
}
// Check for suspicious content
if (containsSuspiciousContent(response_text)) {
return { valid: false, error: 'Response contains invalid content' };
}
// Validate email if provided
if (data.submitted_by_email && !validateEmail(data.submitted_by_email)) {
return { valid: false, error: 'Invalid email address' };
}
return { valid: true };
}
module.exports = {
validatePostalCode,
validateAlbertaPostalCode,
@ -87,5 +133,6 @@ module.exports = {
validateRequiredFields,
containsSuspiciousContent,
generateSlug,
validateSlug
validateSlug,
validateResponse
};

View File

@ -1330,6 +1330,201 @@ create_call_logs_table() {
create_table "$base_id" "influence_call_logs" "$table_data" "Phone call tracking logs"
}
# Function to create the representative responses table
create_representative_responses_table() {
local base_id=$1
local table_data='{
"table_name": "influence_representative_responses",
"title": "Representative Responses",
"columns": [
{
"column_name": "id",
"title": "ID",
"uidt": "ID",
"pk": true,
"ai": true,
"rqd": true
},
{
"column_name": "campaign_id",
"title": "Campaign ID",
"uidt": "SingleLineText",
"rqd": true
},
{
"column_name": "campaign_slug",
"title": "Campaign Slug",
"uidt": "SingleLineText",
"rqd": true
},
{
"column_name": "representative_name",
"title": "Representative Name",
"uidt": "SingleLineText",
"rqd": true
},
{
"column_name": "representative_title",
"title": "Representative Title",
"uidt": "SingleLineText",
"rqd": false
},
{
"column_name": "representative_level",
"title": "Representative Level",
"uidt": "SingleSelect",
"rqd": true,
"colOptions": {
"options": [
{"title": "Federal", "color": "#e74c3c"},
{"title": "Provincial", "color": "#3498db"},
{"title": "Municipal", "color": "#2ecc71"},
{"title": "School Board", "color": "#f39c12"}
]
}
},
{
"column_name": "response_type",
"title": "Response Type",
"uidt": "SingleSelect",
"rqd": true,
"colOptions": {
"options": [
{"title": "Email", "color": "#3498db"},
{"title": "Letter", "color": "#9b59b6"},
{"title": "Phone Call", "color": "#1abc9c"},
{"title": "Meeting", "color": "#e67e22"},
{"title": "Social Media", "color": "#34495e"},
{"title": "Other", "color": "#95a5a6"}
]
}
},
{
"column_name": "response_text",
"title": "Response Text",
"uidt": "LongText",
"rqd": true
},
{
"column_name": "user_comment",
"title": "User Comment",
"uidt": "LongText",
"rqd": false
},
{
"column_name": "screenshot_url",
"title": "Screenshot URL",
"uidt": "SingleLineText",
"rqd": false
},
{
"column_name": "submitted_by_name",
"title": "Submitted By Name",
"uidt": "SingleLineText",
"rqd": false
},
{
"column_name": "submitted_by_email",
"title": "Submitted By Email",
"uidt": "Email",
"rqd": false
},
{
"column_name": "submitted_by_user_id",
"title": "Submitted By User ID",
"uidt": "SingleLineText",
"rqd": false
},
{
"column_name": "is_anonymous",
"title": "Is Anonymous",
"uidt": "Checkbox",
"cdf": "false"
},
{
"column_name": "status",
"title": "Status",
"uidt": "SingleSelect",
"cdf": "pending",
"colOptions": {
"options": [
{"title": "pending", "color": "#f39c12"},
{"title": "approved", "color": "#2ecc71"},
{"title": "rejected", "color": "#e74c3c"}
]
}
},
{
"column_name": "is_verified",
"title": "Is Verified",
"uidt": "Checkbox",
"cdf": "false"
},
{
"column_name": "upvote_count",
"title": "Upvote Count",
"uidt": "Number",
"cdf": "0"
},
{
"column_name": "submitted_ip",
"title": "Submitted IP",
"uidt": "SingleLineText",
"rqd": false
}
]
}'
create_table "$base_id" "influence_representative_responses" "$table_data" "Community responses from representatives"
}
# Function to create the response upvotes table
create_response_upvotes_table() {
local base_id=$1
local table_data='{
"table_name": "influence_response_upvotes",
"title": "Response Upvotes",
"columns": [
{
"column_name": "id",
"title": "ID",
"uidt": "ID",
"pk": true,
"ai": true,
"rqd": true
},
{
"column_name": "response_id",
"title": "Response ID",
"uidt": "SingleLineText",
"rqd": true
},
{
"column_name": "user_id",
"title": "User ID",
"uidt": "SingleLineText",
"rqd": false
},
{
"column_name": "user_email",
"title": "User Email",
"uidt": "Email",
"rqd": false
},
{
"column_name": "upvoted_ip",
"title": "Upvoted IP",
"uidt": "SingleLineText",
"rqd": false
}
]
}'
create_table "$base_id" "influence_response_upvotes" "$table_data" "Track upvotes on responses"
}
# Function to create the users table
create_users_table() {
local base_id="$1"
@ -1451,6 +1646,8 @@ update_env_with_table_ids() {
local campaign_emails_table_id=$6
local users_table_id=$7
local call_logs_table_id=$8
local representative_responses_table_id=$9
local response_upvotes_table_id=${10}
print_status "Updating .env file with NocoDB project and table IDs..."
@ -1486,6 +1683,8 @@ update_env_with_table_ids() {
update_env_var "NOCODB_TABLE_CAMPAIGN_EMAILS" "$campaign_emails_table_id"
update_env_var "NOCODB_TABLE_USERS" "$users_table_id"
update_env_var "NOCODB_TABLE_CALLS" "$call_logs_table_id"
update_env_var "NOCODB_TABLE_REPRESENTATIVE_RESPONSES" "$representative_responses_table_id"
update_env_var "NOCODB_TABLE_RESPONSE_UPVOTES" "$response_upvotes_table_id"
print_success "Successfully updated .env file with all table IDs"
@ -1500,6 +1699,8 @@ update_env_with_table_ids() {
print_status "NOCODB_TABLE_CAMPAIGN_EMAILS=$campaign_emails_table_id"
print_status "NOCODB_TABLE_USERS=$users_table_id"
print_status "NOCODB_TABLE_CALLS=$call_logs_table_id"
print_status "NOCODB_TABLE_REPRESENTATIVE_RESPONSES=$representative_responses_table_id"
print_status "NOCODB_TABLE_RESPONSE_UPVOTES=$response_upvotes_table_id"
}
@ -1607,8 +1808,22 @@ main() {
exit 1
fi
# Create representative responses table
REPRESENTATIVE_RESPONSES_TABLE_ID=$(create_representative_responses_table "$BASE_ID")
if [[ $? -ne 0 ]]; then
print_error "Failed to create representative responses table"
exit 1
fi
# Create response upvotes table
RESPONSE_UPVOTES_TABLE_ID=$(create_response_upvotes_table "$BASE_ID")
if [[ $? -ne 0 ]]; then
print_error "Failed to create response upvotes table"
exit 1
fi
# Validate all table IDs were created successfully
if ! validate_table_ids "$REPRESENTATIVES_TABLE_ID" "$EMAIL_LOGS_TABLE_ID" "$POSTAL_CODES_TABLE_ID" "$CAMPAIGNS_TABLE_ID" "$CAMPAIGN_EMAILS_TABLE_ID" "$USERS_TABLE_ID" "$CALL_LOGS_TABLE_ID"; then
if ! validate_table_ids "$REPRESENTATIVES_TABLE_ID" "$EMAIL_LOGS_TABLE_ID" "$POSTAL_CODES_TABLE_ID" "$CAMPAIGNS_TABLE_ID" "$CAMPAIGN_EMAILS_TABLE_ID" "$USERS_TABLE_ID" "$CALL_LOGS_TABLE_ID" "$REPRESENTATIVE_RESPONSES_TABLE_ID" "$RESPONSE_UPVOTES_TABLE_ID"; then
print_error "One or more table IDs are invalid"
exit 1
fi
@ -1630,6 +1845,8 @@ main() {
table_mapping["influence_campaign_emails"]="$CAMPAIGN_EMAILS_TABLE_ID"
table_mapping["influence_users"]="$USERS_TABLE_ID"
table_mapping["influence_call_logs"]="$CALL_LOGS_TABLE_ID"
table_mapping["influence_representative_responses"]="$REPRESENTATIVE_RESPONSES_TABLE_ID"
table_mapping["influence_response_upvotes"]="$RESPONSE_UPVOTES_TABLE_ID"
# Get source table information
local source_tables_response
@ -1682,6 +1899,8 @@ main() {
print_status " - influence_campaign_emails (ID: $CAMPAIGN_EMAILS_TABLE_ID)"
print_status " - influence_users (ID: $USERS_TABLE_ID)"
print_status " - influence_call_logs (ID: $CALL_LOGS_TABLE_ID)"
print_status " - influence_representative_responses (ID: $REPRESENTATIVE_RESPONSES_TABLE_ID)"
print_status " - influence_response_upvotes (ID: $RESPONSE_UPVOTES_TABLE_ID)"
# Automatically update .env file with new project ID
print_status ""
@ -1704,7 +1923,7 @@ main() {
fi
# Update .env file with table IDs
update_env_with_table_ids "$BASE_ID" "$REPRESENTATIVES_TABLE_ID" "$EMAIL_LOGS_TABLE_ID" "$POSTAL_CODES_TABLE_ID" "$CAMPAIGNS_TABLE_ID" "$CAMPAIGN_EMAILS_TABLE_ID" "$USERS_TABLE_ID" "$CALL_LOGS_TABLE_ID"
update_env_with_table_ids "$BASE_ID" "$REPRESENTATIVES_TABLE_ID" "$EMAIL_LOGS_TABLE_ID" "$POSTAL_CODES_TABLE_ID" "$CAMPAIGNS_TABLE_ID" "$CAMPAIGN_EMAILS_TABLE_ID" "$USERS_TABLE_ID" "$CALL_LOGS_TABLE_ID" "$REPRESENTATIVE_RESPONSES_TABLE_ID" "$RESPONSE_UPVOTES_TABLE_ID"
print_status ""
print_status "============================================================"