Add health check utility, logger, metrics, backup, and SMTP toggle scripts

- Implemented a comprehensive health check utility to monitor system dependencies including NocoDB, SMTP, Represent API, disk space, and memory usage.
- Created a logger utility using Winston for structured logging with daily rotation and various log levels.
- Developed a metrics utility using Prometheus client to track application performance metrics such as email sends, HTTP requests, and user activity.
- Added a backup script for automated backups of NocoDB data, uploaded files, and environment configurations with optional S3 support.
- Introduced a toggle script to switch between development (MailHog) and production (ProtonMail) SMTP configurations.
This commit is contained in:
admin 2025-10-23 11:33:00 -06:00
parent 4b5e2249dd
commit e5c32ad25a
34 changed files with 3872 additions and 1357 deletions

View File

@ -185,6 +185,9 @@ initialize_available_ports() {
["MAP_PORT"]=3000 ["MAP_PORT"]=3000
["INFLUENCE_PORT"]=3333 ["INFLUENCE_PORT"]=3333
["MINI_QR_PORT"]=8089 ["MINI_QR_PORT"]=8089
["REDIS_PORT"]=6379
["PROMETHEUS_PORT"]=9090
["GRAFANA_PORT"]=3001
) )
# Find available ports for each service # Find available ports for each service
@ -252,6 +255,11 @@ MAP_PORT=${MAP_PORT:-3000}
INFLUENCE_PORT=${INFLUENCE_PORT:-3333} INFLUENCE_PORT=${INFLUENCE_PORT:-3333}
MINI_QR_PORT=${MINI_QR_PORT:-8089} MINI_QR_PORT=${MINI_QR_PORT:-8089}
# Centralized Services Ports
REDIS_PORT=${REDIS_PORT:-6379}
PROMETHEUS_PORT=${PROMETHEUS_PORT:-9090}
GRAFANA_PORT=${GRAFANA_PORT:-3001}
# Domain Configuration # Domain Configuration
BASE_DOMAIN=https://changeme.org BASE_DOMAIN=https://changeme.org
DOMAIN=changeme.org DOMAIN=changeme.org
@ -301,6 +309,21 @@ NOCODB_DB_PASSWORD=changeMe
# Gitea Database Configuration # Gitea Database Configuration
GITEA_DB_PASSWD=changeMe GITEA_DB_PASSWD=changeMe
GITEA_DB_ROOT_PASSWORD=changeMe GITEA_DB_ROOT_PASSWORD=changeMe
# Centralized Services Configuration
# Redis (used by all applications for caching, sessions, queues)
REDIS_HOST=redis-changemaker
REDIS_PORT=6379
REDIS_PASSWORD=
# Prometheus (metrics collection)
PROMETHEUS_PORT=9090
PROMETHEUS_RETENTION_TIME=30d
# Grafana (monitoring dashboards)
GRAFANA_PORT=3001
GRAFANA_ADMIN_USER=admin
GRAFANA_ADMIN_PASSWORD=changeMe
EOL EOL
echo "New .env file created with conflict-free port assignments." echo "New .env file created with conflict-free port assignments."
@ -321,6 +344,11 @@ EOL
echo "Map: ${MAP_PORT:-3000}" echo "Map: ${MAP_PORT:-3000}"
echo "Influence: ${INFLUENCE_PORT:-3333}" echo "Influence: ${INFLUENCE_PORT:-3333}"
echo "Mini QR: ${MINI_QR_PORT:-8089}" echo "Mini QR: ${MINI_QR_PORT:-8089}"
echo ""
echo "=== Centralized Services ==="
echo "Redis: ${REDIS_PORT:-6379}"
echo "Prometheus: ${PROMETHEUS_PORT:-9090}"
echo "Grafana: ${GRAFANA_PORT:-3001}"
echo "================================" echo "================================"
} }
@ -383,6 +411,8 @@ update_services_yaml() {
["Mini QR"]="qr.$new_domain" ["Mini QR"]="qr.$new_domain"
["n8n"]="n8n.$new_domain" ["n8n"]="n8n.$new_domain"
["Gitea"]="git.$new_domain" ["Gitea"]="git.$new_domain"
["Prometheus"]="prometheus.$new_domain"
["Grafana"]="grafana.$new_domain"
) )
# Process each service mapping # Process each service mapping
@ -537,6 +567,12 @@ ingress:
- hostname: qr.$new_domain - hostname: qr.$new_domain
service: http://localhost:${MINI_QR_PORT:-8089} service: http://localhost:${MINI_QR_PORT:-8089}
- hostname: prometheus.$new_domain
service: http://localhost:${PROMETHEUS_PORT:-9090}
- hostname: grafana.$new_domain
service: http://localhost:${GRAFANA_PORT:-3001}
# Catch-all rule (required) # Catch-all rule (required)
- service: http_status:404 - service: http_status:404
EOL EOL
@ -1173,6 +1209,10 @@ update_env_var "GITEA_DB_PASSWD" "$gitea_db_password"
gitea_db_root_password=$(generate_password 20) gitea_db_root_password=$(generate_password 20)
update_env_var "GITEA_DB_ROOT_PASSWORD" "$gitea_db_root_password" update_env_var "GITEA_DB_ROOT_PASSWORD" "$gitea_db_root_password"
# Generate and update Grafana admin password
grafana_admin_password=$(generate_password 20)
update_env_var "GRAFANA_ADMIN_PASSWORD" "$grafana_admin_password"
echo "Secure passwords generated and updated." echo "Secure passwords generated and updated."
echo -e "\n✅ Configuration completed successfully!" echo -e "\n✅ Configuration completed successfully!"
@ -1185,6 +1225,8 @@ echo "- Map .env updated with domain settings"
echo "- Listmonk Admin: $listmonk_user" echo "- Listmonk Admin: $listmonk_user"
echo "- N8N Admin Email: $n8n_email" echo "- N8N Admin Email: $n8n_email"
echo "- Secure random passwords for database, encryption, and NocoDB" echo "- Secure random passwords for database, encryption, and NocoDB"
echo "- Grafana Admin Password: Generated (see .env file)"
echo "- Centralized services: Redis, Prometheus, Grafana"
echo "- Tunnel configuration updated at: $TUNNEL_CONFIG_FILE" echo "- Tunnel configuration updated at: $TUNNEL_CONFIG_FILE"
echo -e "\nYour .env file is located at: $ENV_FILE" echo -e "\nYour .env file is located at: $ENV_FILE"
echo "A backup of your original .env file was created before modifications." echo "A backup of your original .env file was created before modifications."
@ -1213,6 +1255,13 @@ echo " - Map: http://localhost:${MAP_PORT:-3000}"
echo " - Influence: http://localhost:${INFLUENCE_PORT:-3333}" echo " - Influence: http://localhost:${INFLUENCE_PORT:-3333}"
echo " - Mini QR: http://localhost:${MINI_QR_PORT:-8089}" echo " - Mini QR: http://localhost:${MINI_QR_PORT:-8089}"
echo "" echo ""
echo " Centralized Services (optional monitoring profile):"
echo " - Prometheus: http://localhost:${PROMETHEUS_PORT:-9090}"
echo " - Grafana: http://localhost:${GRAFANA_PORT:-3001} (admin/${GRAFANA_ADMIN_PASSWORD})"
echo ""
echo " To start with monitoring:"
echo " docker compose --profile monitoring up -d"
echo ""
echo "3. When ready for production:" echo "3. When ready for production:"
echo " ./start-production.sh" echo " ./start-production.sh"
echo "" echo ""

View File

@ -0,0 +1,12 @@
apiVersion: 1
providers:
- name: 'default'
orgId: 1
folder: ''
type: file
disableDeletion: false
updateIntervalSeconds: 10
allowUiUpdates: true
options:
path: /etc/grafana/provisioning/dashboards

View File

@ -0,0 +1,11 @@
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
access: proxy
url: http://prometheus-changemaker:9090
isDefault: true
editable: true
jsonData:
timeInterval: 15s

View File

@ -0,0 +1,93 @@
groups:
- name: influence_app_alerts
interval: 30s
rules:
# Application availability
- alert: ApplicationDown
expr: up{job="influence-app"} == 0
for: 2m
labels:
severity: critical
annotations:
summary: "Influence application is down"
description: "The Influence application has been down for more than 2 minutes."
# High error rate
- alert: HighErrorRate
expr: rate(influence_http_requests_total{status_code=~"5.."}[5m]) > 0.1
for: 5m
labels:
severity: warning
annotations:
summary: "High error rate detected"
description: "Application is experiencing {{ $value }} errors per second."
# Email queue backing up
- alert: EmailQueueBacklog
expr: influence_email_queue_size > 100
for: 10m
labels:
severity: warning
annotations:
summary: "Email queue has significant backlog"
description: "Email queue size is {{ $value }}, emails may be delayed."
# High email failure rate
- alert: HighEmailFailureRate
expr: rate(influence_emails_failed_total[5m]) / rate(influence_emails_sent_total[5m]) > 0.2
for: 10m
labels:
severity: warning
annotations:
summary: "High email failure rate"
description: "{{ $value | humanizePercentage }} of emails are failing to send."
# Rate limiting being hit frequently
- alert: FrequentRateLimiting
expr: rate(influence_rate_limit_hits_total[5m]) > 1
for: 5m
labels:
severity: info
annotations:
summary: "Rate limiting triggered frequently"
description: "Rate limits are being hit {{ $value }} times per second."
# Memory usage high
- alert: HighMemoryUsage
expr: (1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) > 0.85
for: 10m
labels:
severity: warning
annotations:
summary: "High memory usage"
description: "Memory usage is above 85% ({{ $value | humanizePercentage }})."
# Failed login attempts spike
- alert: SuspiciousLoginActivity
expr: rate(influence_login_attempts_total{status="failed"}[5m]) > 5
for: 2m
labels:
severity: warning
annotations:
summary: "Suspicious login activity detected"
description: "{{ $value }} failed login attempts per second detected."
# External service failures
- alert: ExternalServiceFailures
expr: rate(influence_external_service_requests_total{status="failed"}[5m]) > 0.5
for: 5m
labels:
severity: warning
annotations:
summary: "External service failures detected"
description: "{{ $labels.service }} is failing at {{ $value }} requests per second."
# High API latency
- alert: HighAPILatency
expr: histogram_quantile(0.95, rate(influence_http_request_duration_seconds_bucket[5m])) > 2
for: 5m
labels:
severity: warning
annotations:
summary: "High API latency"
description: "95th percentile latency is {{ $value }}s for {{ $labels.route }}."

View File

@ -0,0 +1,54 @@
global:
scrape_interval: 15s
evaluation_interval: 15s
external_labels:
monitor: 'changemaker-lite'
# Alertmanager configuration (optional)
alerting:
alertmanagers:
- static_configs:
- targets: []
# Load rules once and periodically evaluate them
rule_files:
- "alerts.yml"
# Scrape configurations
scrape_configs:
# Influence Application Metrics
- job_name: 'influence-app'
static_configs:
- targets: ['influence-app:3333']
metrics_path: '/api/metrics'
scrape_interval: 10s
scrape_timeout: 5s
# N8N Metrics (if available)
- job_name: 'n8n'
static_configs:
- targets: ['n8n-changemaker:5678']
metrics_path: '/metrics'
scrape_interval: 30s
# Redis Metrics (requires redis_exporter - optional)
# Uncomment and add redis_exporter service to enable
# - job_name: 'redis'
# static_configs:
# - targets: ['redis-exporter:9121']
# Listmonk Metrics (if available)
# - job_name: 'listmonk'
# static_configs:
# - targets: ['listmonk-app:9000']
# metrics_path: '/metrics'
# Prometheus self-monitoring
- job_name: 'prometheus'
static_configs:
- targets: ['localhost:9090']
# Docker container metrics (requires cAdvisor - optional)
# - job_name: 'cadvisor'
# static_configs:
# - targets: ['cadvisor:8080']

View File

@ -240,6 +240,96 @@ services:
networks: networks:
- changemaker-lite - changemaker-lite
# Shared Redis - Used by all services for caching, queues, sessions
redis:
image: redis:7-alpine
container_name: redis-changemaker
command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru
ports:
- "6379:6379"
volumes:
- redis-data:/data
restart: always
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
deploy:
resources:
limits:
cpus: '1'
memory: 512M
reservations:
cpus: '0.25'
memory: 256M
networks:
- changemaker-lite
logging:
driver: "json-file"
options:
max-size: "5m"
max-file: "2"
# Prometheus - Metrics collection for all services
prometheus:
image: prom/prometheus:latest
container_name: prometheus-changemaker
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--storage.tsdb.retention.time=30d'
ports:
- "${PROMETHEUS_PORT:-9090}:9090"
volumes:
- ./configs/prometheus:/etc/prometheus
- prometheus-data:/prometheus
restart: always
networks:
- changemaker-lite
profiles:
- monitoring
# Grafana - Metrics visualization for all services
grafana:
image: grafana/grafana:latest
container_name: grafana-changemaker
ports:
- "${GRAFANA_PORT:-3001}:3000"
environment:
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:-admin}
- GF_USERS_ALLOW_SIGN_UP=false
- GF_SERVER_ROOT_URL=${GRAFANA_ROOT_URL:-http://localhost:3001}
volumes:
- grafana-data:/var/lib/grafana
- ./configs/grafana:/etc/grafana/provisioning
restart: always
depends_on:
- prometheus
networks:
- changemaker-lite
profiles:
- monitoring
# MailHog - Shared email testing service for all applications
# Captures all emails sent by any service for development/testing
# Web UI: http://localhost:8025
# SMTP: mailhog-changemaker:1025
mailhog:
image: mailhog/mailhog:latest
container_name: mailhog-changemaker
ports:
- "1025:1025" # SMTP server
- "8025:8025" # Web UI
restart: unless-stopped
networks:
- changemaker-lite
logging:
driver: "json-file"
options:
max-size: "5m"
max-file: "2"
networks: networks:
changemaker-lite: changemaker-lite:
driver: bridge driver: bridge
@ -250,4 +340,7 @@ volumes:
nc_data: nc_data:
db_data: db_data:
gitea_data: gitea_data:
mysql_data: mysql_data:
redis-data:
prometheus-data:
grafana-data:

View File

@ -1,139 +0,0 @@
# Bug Fix: Response Verification Data Not Populating
## Issue Description
Verification fields were not being saved to the database when submitting responses through the Response Wall. The fields (`Representative Email`, `Verification Token`, etc.) were being created as `null` values.
## Root Causes
### 1. Missing Field Mapping in NocoDB Service
**File:** `app/services/nocodb.js`
**Problem:** The `createRepresentativeResponse()` and `updateRepresentativeResponse()` functions were missing the mappings for the new verification fields.
**Solution:** Added proper Column Title mappings following NocoDB conventions:
```javascript
// Added to createRepresentativeResponse
'Representative Email': responseData.representative_email,
'Verification Token': responseData.verification_token,
'Verification Sent At': responseData.verification_sent_at,
'Verified At': responseData.verified_at,
'Verified By': responseData.verified_by,
// Added to updateRepresentativeResponse
if (updates.representative_email !== undefined) data['Representative Email'] = updates.representative_email;
if (updates.verification_token !== undefined) data['Verification Token'] = updates.verification_token;
if (updates.verification_sent_at !== undefined) data['Verification Sent At'] = updates.verification_sent_at;
if (updates.verified_at !== undefined) data['Verified At'] = updates.verified_at;
if (updates.verified_by !== undefined) data['Verified By'] = updates.verified_by;
```
### 2. Representative Email Coming as Array
**File:** `app/public/js/response-wall.js`
**Problem:** The Represent API sometimes returns email as an array `["email@example.com"]` instead of a string. This caused the form to submit an array value.
**Solution:** Added array handling in `handleRepresentativeSelect()`:
```javascript
// Handle email being either string or array
const emailValue = Array.isArray(rep.email) ? rep.email[0] : rep.email;
document.getElementById('representative-email').value = emailValue;
```
### 3. Anonymous Checkbox Value as String
**File:** `app/controllers/responses.js`
**Problem:** HTML checkboxes send the value "on" when checked, not boolean `true`. This was being stored as the string "on" in the database.
**Solution:** Added proper checkbox normalization:
```javascript
// Normalize is_anonymous checkbox value
const isAnonymous = responseData.is_anonymous === true ||
responseData.is_anonymous === 'true' ||
responseData.is_anonymous === 'on';
```
### 4. Backend Email Handling
**File:** `app/controllers/responses.js`
**Problem:** The backend wasn't handling the case where `representative_email` might come as an array from the form.
**Solution:** Added array handling in backend:
```javascript
// Handle representative_email - could be string or array from form
let representativeEmail = responseData.representative_email;
if (Array.isArray(representativeEmail)) {
representativeEmail = representativeEmail[0]; // Take first email if array
}
representativeEmail = representativeEmail || null;
```
Also added support for "on" value in verification checkbox:
```javascript
const sendVerification = responseData.send_verification === 'true' ||
responseData.send_verification === true ||
responseData.send_verification === 'on';
```
## Files Modified
1. ✅ `app/services/nocodb.js` - Added verification field mappings
2. ✅ `app/public/js/response-wall.js` - Fixed email array handling
3. ✅ `app/controllers/responses.js` - Fixed checkbox values and email array handling
## Testing Performed
### Before Fix
- Representative Email: `null` in database
- Verification Token: `null` in database
- Is Anonymous: String `"on"` instead of boolean
- No verification emails sent
### After Fix
- ✅ Representative Email: Correctly stored as string
- ✅ Verification Token: 64-character hex string generated
- ✅ Verification Sent At: ISO timestamp
- ✅ Is Anonymous: Boolean `true` or `false`
- ✅ Verification email sent successfully
## NocoDB Best Practices Applied
Following the guidelines from `instruct.md`:
1. **Use Column Titles, Not Column Names:** All field mappings use NocoDB Column Titles (e.g., "Representative Email" not "representative_email")
2. **Consistent Mapping:** Service layer properly maps between application field names and NocoDB column titles
3. **System Field Awareness:** Avoided conflicts with NocoDB system fields
## Deployment
No database schema changes required - the columns already exist from the previous deployment. Only code changes needed:
```bash
# Rebuild Docker container
docker compose build && docker compose up -d
```
## Verification Checklist
After deployment, verify:
- [ ] Submit a response with postal code lookup
- [ ] Select representative with email address
- [ ] Check "Send verification request" checkbox
- [ ] Submit form
- [ ] Verify in database:
- [ ] Representative Email is populated (string, not array)
- [ ] Verification Token is 64-char hex string
- [ ] Verification Sent At has ISO timestamp
- [ ] Is Anonymous is boolean
- [ ] Check MailHog/email for verification email
- [ ] Click verification link and confirm it works
## Related Documentation
- `IMPLEMENTATION_SUMMARY.md` - Full feature implementation
- `DEPLOYMENT_GUIDE.md` - Deployment instructions
- `instruct.md` - NocoDB best practices
---
**Date Fixed:** October 16, 2025
**Status:** ✅ Resolved

View File

@ -1,210 +0,0 @@
# Response Wall Verification Feature - Deployment Guide
## Overview
This guide walks you through deploying the new Response Wall verification features that were added to the Influence Campaign Tool.
## Features Implemented
### 1. Postal Code Lookup for Response Submission
- Users can search by postal code to find their representatives
- Auto-fills representative details when selected
- Validates Alberta postal codes (T prefix)
- Fallback to manual entry if needed
### 2. Representative Verification System
- Optional email verification for submitted responses
- Representatives receive verification emails with unique tokens
- Representatives can verify or report responses
- Verified responses display with special badge
- Disputed responses are hidden from public view
## Deployment Steps
### Step 1: Update Database Schema
Run the NocoDB setup script to create/update tables with new verification fields:
```bash
cd /path/to/influence
./scripts/build-nocodb.sh
```
**If you already have existing tables**, you'll need to manually add the new columns through NocoDB UI:
1. Log into your NocoDB instance
2. Open the `influence_representative_responses` table
3. Add these columns:
- `representative_email` - Type: Email, Required: No
- `verification_token` - Type: SingleLineText, Required: No
- `verification_sent_at` - Type: DateTime, Required: No
- `verified_at` - Type: DateTime, Required: No
- `verified_by` - Type: SingleLineText, Required: No
### Step 2: Update Environment Variables
Add these variables to your `.env` file:
```bash
# Application Name (used in emails)
APP_NAME="BNKops Influence"
# Base URL for verification links
BASE_URL=https://yourdomain.com
# Existing variables to verify:
SMTP_HOST=your-smtp-host
SMTP_PORT=587
SMTP_USER=your-email@domain.com
SMTP_PASS=your-password
SMTP_FROM_EMAIL=your-email@domain.com
SMTP_FROM_NAME="Your Campaign Name"
```
⚠️ **Important:** The `BASE_URL` must be your production domain for verification links to work correctly.
### Step 3: Rebuild Docker Container (if using Docker)
```bash
cd /path/to/influence
docker compose build
docker compose up -d
```
### Step 4: Verify Email Templates
Ensure the email templates are in place:
```bash
ls -la app/templates/email/
```
You should see:
- `response-verification.html`
- `response-verification.txt`
### Step 5: Test the Feature
#### Test Postal Code Lookup:
1. Go to any campaign's Response Wall
2. Click "Share a Response"
3. Enter postal code (e.g., T5K 2J1)
4. Click Search
5. Verify representatives appear
6. Select a representative
7. Confirm form auto-fills
#### Test Verification Email:
1. Complete the form with all required fields
2. Check "Send verification request to representative"
3. Submit the response
4. Check that confirmation message mentions email sent
5. Check representative's email inbox for verification email
#### Test Verification Flow:
1. Open verification email
2. Click "Verify This Response" button
3. Should see green success page
4. Check Response Wall - response should have verified badge
5. Check admin panel - response should be auto-approved
#### Test Report Flow:
1. Open verification email for a different response
2. Click "Report as Invalid" button
3. Should see warning page
4. Check Response Wall - response should be hidden
5. Check admin panel - response should be marked as rejected
## Production Checklist
- [ ] Database schema updated with new verification fields
- [ ] Environment variables configured (APP_NAME, BASE_URL)
- [ ] Email templates exist and are readable
- [ ] SMTP settings are correct and tested
- [ ] Docker container rebuilt and running
- [ ] Postal code search tested successfully
- [ ] Verification email sent and received
- [ ] Verification link works and updates database
- [ ] Report link works and hides response
- [ ] Verified badge displays on Response Wall
- [ ] Admin panel shows verification status correctly
## Security Notes
1. **Token Security**: Verification tokens are 32-byte cryptographically secure random strings
2. **Token Expiry**: Consider implementing token expiration (currently no expiry - tokens work indefinitely)
3. **Rate Limiting**: Existing rate limiting applies to submission endpoint
4. **Email Validation**: Representative emails are validated on backend
5. **XSS Prevention**: All user inputs are sanitized before display
## Troubleshooting
### Verification Emails Not Sending
- Check SMTP settings in `.env`
- Verify SMTP credentials are correct
- Check application logs: `docker logs influence-app -f`
- Test email connection: Use email test page at `/email-test.html`
### Postal Code Search Returns No Results
- Verify Represent API is accessible
- Check `REPRESENT_API_BASE` in `.env`
- Ensure postal code is Alberta format (starts with T)
- Check browser console for errors
### Verification Links Don't Work
- Verify `BASE_URL` in `.env` matches your domain
- Check that verification token was saved to database
- Ensure response ID is correct
- Check application logs for errors
### Representative Dropdown Not Populating
- Check browser console for JavaScript errors
- Verify `api-client.js` is loaded in HTML
- Ensure API endpoint `/api/representatives/by-postal/:code` is accessible
- Check network tab for API response
## Rollback Plan
If you need to rollback this feature:
1. **Frontend Only Rollback**:
```bash
# Restore old files
git checkout HEAD~1 -- app/public/response-wall.html
git checkout HEAD~1 -- app/public/js/response-wall.js
git checkout HEAD~1 -- app/public/css/response-wall.css
```
2. **Full Rollback** (including backend):
```bash
# Restore all files
git checkout HEAD~1
docker compose build
docker compose up -d
```
3. **Database Cleanup** (optional):
- The new columns don't hurt anything if left in place
- You can manually remove them through NocoDB UI if desired
## Support
For issues or questions:
- Check application logs: `docker logs influence-app -f`
- Review `RESPONSE_WALL_UPDATES.md` for implementation details
- Check `files-explainer.md` for file structure information
## Next Steps
### Recommended Enhancements:
1. **Token Expiration**: Implement 30-day expiration for verification tokens
2. **Email Notifications**: Notify submitter when representative verifies
3. **Analytics Dashboard**: Track verification rates and response authenticity
4. **Bulk Verification**: Allow representatives to verify multiple responses at once
5. **Representative Dashboard**: Create dedicated portal for representatives to manage responses
### Future Features:
1. Support for other provinces beyond Alberta
2. SMS verification option
3. Representative accounts for ongoing engagement
4. Response comment system for public discussion
5. Export verified responses for accountability reporting

View File

@ -1,455 +0,0 @@
# Response Wall Verification Feature - Implementation Summary
## ✅ COMPLETED - October 16, 2025
## Overview
Successfully implemented a comprehensive response verification system for the BNKops Influence Campaign Tool's Response Wall feature. The system allows constituents to submit representative responses with optional email verification, enabling representatives to authenticate submissions.
## Features Implemented
### 1. Postal Code Lookup Integration ✅
**Location:** Frontend (response-wall.html, response-wall.js, response-wall.css)
**Features:**
- Search by Alberta postal code (T prefix validation)
- Fetches representatives from Represent API
- Interactive dropdown selection list
- Auto-fills form fields when representative selected:
- Representative name
- Title/office position
- Government level (Federal/Provincial/Municipal/School Board)
- Email address (hidden field)
- Fallback to manual entry if search fails
- Format validation and user-friendly error messages
**Implementation Details:**
- Reuses existing API client (`api-client.js`)
- Validates Canadian postal code format (A1A 1A1)
- Government level auto-detection from office type
- Responsive UI with clear user feedback
### 2. Response Verification System ✅
**Location:** Backend (controllers/responses.js, services/email.js, templates/email/)
**Features:**
- Optional verification checkbox on submission form
- Checkbox auto-disabled if no representative email available
- Generates cryptographically secure verification tokens
- Sends professional HTML/text email to representative
- Unique verification and report URLs for each submission
- Styled confirmation pages for both actions
**Email Template Features:**
- Professional design with gradient backgrounds
- Clear call-to-action buttons
- Response preview in email body
- Campaign and submitter details
- Explanation of verification purpose
- Mobile-responsive design
### 3. Verification Endpoints ✅
**Location:** Backend (controllers/responses.js, routes/api.js)
**Endpoints:**
- `GET /api/responses/:id/verify/:token` - Verify response as authentic
- `GET /api/responses/:id/report/:token` - Report response as invalid
**Security Features:**
- Token validation before any action
- Protection against duplicate verification
- Clear error messages for invalid/expired tokens
- Styled HTML pages instead of JSON responses
- Auto-approval of verified responses
**Actions on Verification:**
- Sets `is_verified: true`
- Records `verified_at` timestamp
- Records `verified_by` (representative email)
- Auto-approves response (`status: 'approved'`)
- Response displays with verification badge
**Actions on Report:**
- Sets `status: 'rejected'`
- Sets `is_verified: false`
- Records dispute in `verified_by` field
- Hides response from public view
- Queues for admin review
## Files Created
### Frontend Files
1. **Modified:** `app/public/response-wall.html`
- Added postal code search input and button
- Added representative selection dropdown
- Added hidden representative email field
- Added verification checkbox with description
- Included api-client.js dependency
2. **Modified:** `app/public/js/response-wall.js`
- Added postal lookup functions
- Added representative selection handling
- Added form auto-fill logic
- Added government level detection
- Updated form submission to include verification data
- Updated modal reset to clear new fields
3. **Modified:** `app/public/css/response-wall.css`
- Added postal lookup container styles
- Added representative dropdown styles
- Added checkbox styling improvements
- Added responsive design for mobile
### Backend Files
4. **Modified:** `app/controllers/responses.js`
- Updated `submitResponse()` to handle verification
- Added verification token generation (crypto)
- Added verification email sending logic
- Created `verifyResponse()` endpoint function
- Created `reportResponse()` endpoint function
- Added styled HTML response pages
5. **Modified:** `app/services/email.js`
- Added `sendResponseVerification()` method
- Configured template variables for verification emails
- Added error handling for email failures
6. **Created:** `app/templates/email/response-verification.html`
- Professional HTML email template
- Gradient header design
- Clear verify/report buttons
- Response preview section
- Mobile-responsive layout
7. **Created:** `app/templates/email/response-verification.txt`
- Plain text email version
- All essential information included
- Accessible format for all email clients
8. **Modified:** `app/routes/api.js`
- Added verification endpoint routes
- Public access (no authentication required)
- Proper route ordering
### Database Files
9. **Modified:** `scripts/build-nocodb.sh`
- Added `representative_email` column (Email type)
- Added `verification_token` column (SingleLineText)
- Added `verification_sent_at` column (DateTime)
- Added `verified_at` column (DateTime)
- Added `verified_by` column (SingleLineText)
### Documentation Files
10. **Created:** `RESPONSE_WALL_UPDATES.md`
- Complete feature documentation
- Frontend implementation details
- Backend requirements and implementation
- User flow descriptions
- Testing checklist
- Security considerations
11. **Created:** `DEPLOYMENT_GUIDE.md`
- Step-by-step deployment instructions
- Database schema update procedures
- Environment variable configuration
- Testing procedures for all features
- Production checklist
- Troubleshooting guide
- Rollback plan
12. **Modified:** `example.env`
- Added `APP_NAME` variable
- Added `BASE_URL` variable for verification links
- Updated documentation
13. **Created:** `IMPLEMENTATION_SUMMARY.md` (this file)
## Database Schema Changes
### Table: influence_representative_responses
**New Columns Added:**
| Column Name | Type | Required | Description |
|-------------|------|----------|-------------|
| representative_email | Email | No | Email address of the representative |
| verification_token | SingleLineText | No | Unique token for verification (32-byte hex) |
| verification_sent_at | DateTime | No | Timestamp when verification email was sent |
| verified_at | DateTime | No | Timestamp when response was verified |
| verified_by | SingleLineText | No | Who verified (email or "Disputed by...") |
## API Changes
### Modified Endpoints
#### POST `/api/campaigns/:slug/responses`
**New Request Fields:**
```javascript
{
// ... existing fields ...
representative_email: String, // Optional: Representative's email
send_verification: Boolean|String, // Optional: 'true' to send email
}
```
**New Response Fields:**
```javascript
{
success: Boolean,
message: String, // Updated to mention verification
response: Object,
verificationEmailSent: Boolean // New: indicates if email sent
}
```
### New Endpoints
#### GET `/api/responses/:id/verify/:token`
**Purpose:** Verify a response as authentic
**Authentication:** None required (public link)
**Response:** Styled HTML page with success/error message
**Side Effects:**
- Updates `is_verified` to true
- Records `verified_at` timestamp
- Records `verified_by` field
- Auto-approves response
#### GET `/api/responses/:id/report/:token`
**Purpose:** Report a response as invalid
**Authentication:** None required (public link)
**Response:** Styled HTML page with confirmation
**Side Effects:**
- Updates `status` to 'rejected'
- Sets `is_verified` to false
- Records dispute in `verified_by`
- Hides from public view
## Environment Variables Required
```bash
# Required for verification feature
APP_NAME="BNKops Influence" # App name for emails
BASE_URL=https://yourdomain.com # Base URL for verification links
# Required for email sending
SMTP_HOST=smtp.provider.com
SMTP_PORT=587
SMTP_USER=email@domain.com
SMTP_PASS=password
SMTP_FROM_EMAIL=sender@domain.com
SMTP_FROM_NAME="Campaign Name"
```
## User Flow
### Submission Flow with Verification
1. User opens Response Wall for a campaign
2. User clicks "Share a Response" button
3. **[NEW]** User enters postal code and clicks Search
4. **[NEW]** System displays list of representatives
5. **[NEW]** User selects their representative
6. **[NEW]** Form auto-fills with rep details
7. User completes response details (type, text, comment, screenshot)
8. **[NEW]** User optionally checks "Send verification request"
9. User submits form
10. **[NEW]** System generates verification token (if opted in)
11. System saves response with pending status
12. **[NEW]** System sends verification email (if opted in)
13. User sees success message
### Verification Flow
1. Representative receives verification email
2. Representative reviews response content
3. Representative clicks "Verify This Response"
4. System validates token
5. System updates response to verified
6. System auto-approves response
7. Representative sees styled success page
8. Response appears on Response Wall with verified badge
### Report Flow
1. Representative receives verification email
2. Representative identifies invalid response
3. Representative clicks "Report as Invalid"
4. System validates token
5. System marks response as rejected/disputed
6. System hides response from public view
7. Representative sees styled confirmation page
8. Admin can review disputed responses
## Security Implementation
### Token Security
- **Generation:** Crypto.randomBytes(32) - 256-bit entropy
- **Storage:** Plain text in database (tokens are one-time use)
- **Validation:** Exact string match required
- **Expiration:** Currently no expiration (recommend 30-day TTL)
### Input Validation
- **Postal Codes:** Regex validation for Canadian format
- **Emails:** Email type validation in NocoDB
- **Response Data:** Required field validation
- **Tokens:** URL parameter validation
### Rate Limiting
- Existing rate limiters apply to submission endpoint
- No rate limiting on verification endpoints (public, one-time use)
### XSS Prevention
- All user inputs escaped before email inclusion
- HTML entities encoded in styled pages
- Template variable substitution prevents injection
## Testing Results
### Manual Testing Completed
✅ Postal code search with valid Alberta codes
✅ Validation rejection of non-Alberta codes
✅ Representative dropdown population
✅ Representative selection auto-fill
✅ Verification checkbox disabled without email
✅ Verification checkbox enabled with email
✅ Form submission with verification flag
✅ Backend verification parameter handling
✅ Verification email delivery
✅ Verification link functionality
✅ Report link functionality
✅ Styled HTML page rendering
✅ Token validation
✅ Duplicate verification handling
✅ Invalid token rejection
✅ Manual entry without postal lookup
✅ Modal reset clearing new fields
### Integration Testing Required
⚠️ End-to-end flow with real representative email
⚠️ Email deliverability to various providers
⚠️ Mobile responsive testing
⚠️ Browser compatibility (Chrome, Firefox, Safari, Edge)
⚠️ Load testing for concurrent submissions
⚠️ Token collision testing (extremely unlikely but should verify)
## Performance Considerations
### Frontend
- Postal lookup adds one API call per search
- Representative list rendering: O(n) where n = representatives count
- No significant performance impact expected
### Backend
- Token generation: Negligible CPU impact
- Email sending: Asynchronous, doesn't block response
- Database writes: 5 additional columns per response
- No new indexes required (token not queried frequently)
### Email
- Email sending happens after response saved
- Failures don't affect submission success
- Consider queue system for high-volume deployments
## Known Limitations
1. **Postal Code Validation:** Only supports Alberta (T prefix)
- **Recommendation:** Extend to other provinces
2. **Token Expiration:** Tokens don't expire
- **Recommendation:** Implement 30-day expiration
3. **Email Required:** Verification requires email address
- **Recommendation:** Support phone verification for reps without email
4. **No Notification:** Submitter not notified when verified
- **Recommendation:** Add email notification to submitter
5. **Single Verification:** Can only verify once per token
- **Recommendation:** Consider revocation system
## Future Enhancements
### Short Term (1-3 months)
1. Token expiration (30 days)
2. Submitter notification emails
3. Verification analytics dashboard
4. Support for other Canadian provinces
5. Admin verification override
### Medium Term (3-6 months)
1. Representative dashboard for bulk verification
2. SMS verification option
3. Response comment system
4. Verification badge prominence settings
5. Export verified responses
### Long Term (6-12 months)
1. Full representative portal with authentication
2. Two-way communication system
3. Automated verification reminders
4. Public verification statistics
5. API for third-party integrations
## Rollback Procedures
### If Issues Arise
**Level 1 - Frontend Only:**
```bash
git checkout HEAD~1 -- app/public/response-wall.*
docker compose restart
```
**Level 2 - Backend Only:**
```bash
git checkout HEAD~1 -- app/controllers/responses.js app/services/email.js
docker compose restart
```
**Level 3 - Full Rollback:**
```bash
git checkout HEAD~1
docker compose build && docker compose up -d
```
**Database Cleanup (optional):**
- New columns can remain without causing issues
- Remove via NocoDB UI if desired
## Maintenance Notes
### Regular Tasks
- Monitor verification email delivery rates
- Review disputed responses in admin panel
- Check for expired tokens (when expiration implemented)
- Monitor token collision (extremely unlikely)
### Monitoring Metrics
- Verification email success/failure rate
- Verification vs. report ratio
- Time to verification (submission → verification)
- Disputed response resolution time
## Support Information
### Log Locations
```bash
# Application logs
docker logs influence-app -f
# Email service logs
grep "verification email" logs/app.log
```
### Common Issues
See `DEPLOYMENT_GUIDE.md` troubleshooting section
### Contact
- Technical Issues: Check application logs
- Feature Requests: Document in project issues
- Security Concerns: Report to security team immediately
## Conclusion
The Response Wall verification feature has been successfully implemented with comprehensive frontend and backend support. The system provides a secure, user-friendly way for constituents to submit representative responses with optional verification, enhancing transparency and accountability in political engagement.
All code is production-ready, well-documented, and follows the project's architectural patterns. The feature integrates seamlessly with existing functionality while adding significant value to the platform.
**Status:** ✅ Ready for Production Deployment
**Date Completed:** October 16, 2025
**Version:** 1.0.0

View File

@ -1,177 +0,0 @@
# Quick Reference: Response Verification Feature
## Quick Start
### For Developers
```bash
# 1. Update database
./scripts/build-nocodb.sh
# 2. Update .env
echo 'APP_NAME="BNKops Influence"' >> .env
echo 'BASE_URL=http://localhost:3333' >> .env
# 3. Rebuild
docker compose build && docker compose up -d
# 4. Test at
open http://localhost:3333/response-wall.html?campaign=your-campaign-slug
```
### For Testers
1. Navigate to any campaign Response Wall
2. Click "Share a Response"
3. Enter postal code: **T5K 2J1**
4. Click Search
5. Select a representative
6. Fill in response details
7. Check "Send verification request"
8. Submit
## File Locations
### Frontend
- `app/public/response-wall.html` - Main page
- `app/public/js/response-wall.js` - Logic
- `app/public/css/response-wall.css` - Styles
### Backend
- `app/controllers/responses.js` - Main controller
- `app/services/email.js` - Email service
- `app/templates/email/response-verification.*` - Email templates
- `app/routes/api.js` - Route definitions
### Database
- `scripts/build-nocodb.sh` - Schema definitions
### Documentation
- `IMPLEMENTATION_SUMMARY.md` - Full implementation details
- `DEPLOYMENT_GUIDE.md` - Deployment instructions
- `RESPONSE_WALL_UPDATES.md` - Feature documentation
## Key Functions
### Frontend (`response-wall.js`)
```javascript
handlePostalLookup() // Searches by postal code
displayRepresentativeOptions() // Shows rep dropdown
handleRepresentativeSelect() // Auto-fills form
handleSubmitResponse() // Submits with verification
```
### Backend (`responses.js`)
```javascript
submitResponse() // Handles submission + verification
verifyResponse() // Verifies via token
reportResponse() // Reports as invalid
```
### Email Service (`email.js`)
```javascript
sendResponseVerification() // Sends verification email
```
## API Endpoints
```
POST /api/campaigns/:slug/responses # Submit response
GET /api/responses/:id/verify/:token # Verify response
GET /api/responses/:id/report/:token # Report response
```
## Database Fields
**Table:** influence_representative_responses
| Field | Type | Purpose |
|-------|------|---------|
| representative_email | Email | Rep's email address |
| verification_token | Text | 32-byte random hex |
| verification_sent_at | DateTime | When email sent |
| verified_at | DateTime | When verified |
| verified_by | Text | Who verified |
## Environment Variables
```bash
APP_NAME="BNKops Influence"
BASE_URL=https://yourdomain.com
SMTP_HOST=smtp.provider.com
SMTP_PORT=587
SMTP_USER=email@domain.com
SMTP_PASS=password
SMTP_FROM_EMAIL=sender@domain.com
SMTP_FROM_NAME="Campaign Name"
```
## Testing Checklist
**Frontend:**
- [ ] Postal search works
- [ ] Rep dropdown populates
- [ ] Form auto-fills
- [ ] Checkbox enables/disables
- [ ] Submission succeeds
**Backend:**
- [ ] Token generated
- [ ] Email sent
- [ ] Verification works
- [ ] Report works
- [ ] HTML pages display
**Security:**
- [ ] Invalid tokens rejected
- [ ] Duplicate verification handled
- [ ] XSS prevention working
## Common Issues
### Email Not Sending
- Check SMTP settings in `.env`
- Test at `/email-test.html`
- Check logs: `docker logs influence-app -f`
### Postal Search Fails
- Verify Represent API accessible
- Check postal code format (T5K 2J1)
- Check browser console for errors
### Verification Link Fails
- Verify BASE_URL is correct
- Check token in database
- Check application logs
## URLs for Testing
```
# Main page
http://localhost:3333/response-wall.html?campaign=test-campaign
# Verification (replace ID and TOKEN)
http://localhost:3333/api/responses/123/verify/abc123...
# Report (replace ID and TOKEN)
http://localhost:3333/api/responses/123/report/abc123...
```
## Support
- **Logs:** `docker logs influence-app -f`
- **Docs:** See markdown files in project root
- **Email Test:** http://localhost:3333/email-test.html
## Quick Troubleshooting
| Problem | Solution |
|---------|----------|
| No representatives found | Check postal code format (T5K 2J1) |
| Email not received | Check SMTP settings, spam folder |
| Verification fails | Check BASE_URL, token validity |
| Checkbox disabled | Representative has no email |
| Form won't submit | Check required fields, validation |
---
**Last Updated:** October 16, 2025
**Version:** 1.0.0

View File

@ -1,202 +0,0 @@
# Response Wall Feature Updates
## Overview
Updated the Response Wall submission modal to include two new features:
1. **Postal Code Lookup** - Auto-fill representative details by searching postal codes
2. **Response Verification** - Option to send verification email to representatives
## Frontend Changes Completed
### 1. HTML Updates (`response-wall.html`)
- Added postal code search input field with search button
- Added representative selection dropdown (hidden by default, shows after search)
- Added hidden field for storing representative email
- Added "Send verification request" checkbox option
- Included `api-client.js` script dependency
### 2. JavaScript Updates (`response-wall.js`)
- Added `loadedRepresentatives` array to store search results
- Implemented `formatPostalCodeInput()` - formats postal code as "A1A 1A1"
- Implemented `validatePostalCode()` - validates Canadian (Alberta) postal codes
- Implemented `handlePostalLookup()` - fetches representatives from API
- Implemented `displayRepresentativeOptions()` - populates dropdown with results
- Implemented `handleRepresentativeSelect()` - auto-fills form when rep selected
- Implemented `determineGovernmentLevel()` - maps office type to government level
- Updated `handleSubmitResponse()` - includes verification flag and rep email
- Updated `closeSubmitModal()` - resets postal lookup fields
### 3. CSS Updates (`response-wall.css`)
- Added `.postal-lookup-container` styles for search UI
- Added `#rep-select` and `#rep-select-group` styles for dropdown
- Added checkbox styling improvements
- Added disabled state styling for verification checkbox
## Backend Implementation - ✅ COMPLETED
### 1. API Endpoint Updates - ✅ COMPLETED
#### Update: `POST /api/campaigns/:slug/responses` - ✅ COMPLETED
The endpoint now handles new fields:
**New Request Fields:**
```javascript
{
// ... existing fields ...
representative_email: String, // Email address of the representative
send_verification: Boolean // Whether to send verification email
}
```
**Implementation Requirements:**
1. Accept and validate `representative_email` field
2. Accept `send_verification` boolean flag
3. When `send_verification === true` AND `representative_email` is present:
- Generate a unique verification token
- Store token with the response record
- Send verification email to representative
### 2. Database Schema Updates - ✅ COMPLETED
**responses table additions:** ✅ Implemented in `scripts/build-nocodb.sh`
- `representative_email` - Email field for storing rep email
- `verification_token` - SingleLineText for unique verification token
- `verification_sent_at` - DateTime for tracking when email was sent
- `verified_at` - DateTime for tracking verification timestamp
- `verified_by` - SingleLineText for tracking who verified
### 3. Verification Email Template - ✅ COMPLETED
Created email templates in `app/templates/email/`:
**Subject:** "Verification Request: Response Submission on BNKops Influence"
**Body:**
```
Dear [Representative Name],
A constituent has submitted a response they received from you on the BNKops Influence platform.
Campaign: [Campaign Name]
Response Type: [Email/Letter/etc.]
Submitted: [Date]
To verify this response is authentic, please click the link below:
[Verification Link]
If you did not send this response, please click here to report it:
[Report Link]
This helps maintain transparency and accountability in constituent communications.
Best regards,
BNKops Influence Team
```
### 4. Verification Endpoints (New) - ✅ COMPLETED
#### `GET /api/responses/:id/verify/:token` - ✅ COMPLETED
Implemented in `app/controllers/responses.js`:
- Verifies response using unique token
- Updates `verified_at` timestamp
- Marks response as verified (`is_verified: true`)
- Auto-approves response (`status: 'approved'`)
- Returns styled HTML success page
#### `GET /api/responses/:id/report/:token` - ✅ COMPLETED
Implemented in `app/controllers/responses.js`:
- Marks response as disputed by representative
- Updates response status to 'rejected'
- Sets `is_verified: false`
- Hides from public view (rejected status)
- Returns styled HTML confirmation page
### 5. Email Service Integration - ✅ COMPLETED
Updated email service with verification support:
**File:** `app/services/email.js`
```javascript
async function sendVerificationEmail(responseId, representativeEmail, representativeName, verificationToken) {
const verificationUrl = `${process.env.BASE_URL}/api/responses/${responseId}/verify/${verificationToken}`;
const reportUrl = `${process.env.BASE_URL}/api/responses/${responseId}/report/${verificationToken}`;
// Send email using your email service
// Include verification and report links
}
```
### 6. Environment Variables - ✅ COMPLETED
Added to `example.env`:
```env
APP_NAME="BNKops Influence"
BASE_URL=http://localhost:3333
```
**Note:** Update your `.env` file with these values for production deployment.
## User Flow
### Submitting with Verification
1. User clicks "Share a Response"
2. User enters postal code and clicks search
3. System fetches representatives from Represent API
4. User selects their representative from dropdown
5. Form auto-fills: name, title, level, email (hidden)
6. User completes response details
7. User checks "Send verification request"
8. User submits form
9. **Backend**: Response saved as pending/unverified
10. **Backend**: Verification email sent to representative
11. User sees success message
### Representative Verification
1. Representative receives email
2. Clicks verification link
3. Redirects to verification endpoint
4. Response marked as verified
5. Response becomes visible with "Verified" badge
## Testing Checklist
### Frontend Testing
- [ ] Postal code search works with valid Alberta codes
- [ ] Validation rejects non-Alberta codes
- [ ] Representative dropdown populates correctly
- [ ] Selecting a rep auto-fills form fields
- [ ] Verification checkbox is disabled when no email
- [ ] Verification checkbox is enabled when rep has email
- [ ] Form submits successfully with verification flag
- [ ] Manual entry still works without postal lookup
- [ ] Modal resets properly when closed
### Backend Testing
- [ ] Backend receives verification parameters correctly
- [ ] Verification token is generated and stored
- [ ] Verification email is sent when opted in
- [ ] Email contains correct verification and report URLs
- [ ] Verification endpoint validates token correctly
- [ ] Verification endpoint updates database correctly
- [ ] Report endpoint marks response as disputed
- [ ] Styled HTML pages display correctly on verify/report
- [ ] Security: Invalid tokens are rejected
- [ ] Security: Already verified responses show appropriate message
## Security Considerations
1. **Token Security**: Use cryptographically secure random tokens
2. **Token Expiry**: Verification tokens should expire (e.g., 30 days)
3. **Rate Limiting**: Limit verification emails per IP/session
4. **Email Validation**: Validate representative email format
5. **XSS Prevention**: Sanitize all form inputs on backend
6. **CSRF Protection**: Ensure CSRF tokens on form submission
## Future Enhancements
1. Add notification when representative verifies
2. Show verification status prominently on response cards
3. Add statistics: "X% of responses verified"
4. Allow representatives to add comments during verification
5. Add representative dashboard to manage verifications
6. Support for multiple verification methods (SMS, etc.)

View File

@ -2,6 +2,9 @@ FROM node:18-alpine
WORKDIR /usr/src/app WORKDIR /usr/src/app
# Install curl for healthcheck
RUN apk add --no-cache curl
# Copy package files # Copy package files
COPY package*.json ./ COPY package*.json ./

View File

@ -0,0 +1,104 @@
const csrf = require('csurf');
const logger = require('../utils/logger');
// Create CSRF protection middleware
const csrfProtection = csrf({
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production' && process.env.HTTPS === 'true',
sameSite: 'strict',
maxAge: 3600000 // 1 hour
}
});
/**
* Middleware to handle CSRF token errors
*/
const csrfErrorHandler = (err, req, res, next) => {
if (err.code === 'EBADCSRFTOKEN') {
logger.warn('CSRF token validation failed', {
ip: req.ip,
path: req.path,
method: req.method,
userAgent: req.get('user-agent')
});
return res.status(403).json({
success: false,
error: 'Invalid CSRF token',
message: 'Your session has expired or the request is invalid. Please refresh the page and try again.'
});
}
next(err);
};
/**
* Middleware to inject CSRF token into response
* Adds csrfToken to all JSON responses and as a header
*/
const injectCsrfToken = (req, res, next) => {
// Add token to response locals for template rendering
res.locals.csrfToken = req.csrfToken();
// Override json method to automatically include CSRF token
const originalJson = res.json.bind(res);
res.json = function(data) {
if (data && typeof data === 'object' && !data.csrfToken) {
data.csrfToken = res.locals.csrfToken;
}
return originalJson(data);
};
next();
};
/**
* Skip CSRF protection for specific routes (e.g., webhooks, public APIs)
*/
const csrfExemptRoutes = [
'/api/health',
'/api/metrics',
'/api/config',
'/api/auth/login', // Login uses credentials for authentication
'/api/auth/session', // Session check is read-only
'/api/representatives/postal/', // Read-only operation
'/api/campaigns/public' // Public read operations
];
const conditionalCsrfProtection = (req, res, next) => {
// Skip CSRF for exempt routes
const isExempt = csrfExemptRoutes.some(route => req.path.startsWith(route));
// Skip CSRF for GET, HEAD, OPTIONS (safe methods)
const isSafeMethod = ['GET', 'HEAD', 'OPTIONS'].includes(req.method);
if (isExempt || isSafeMethod) {
return next();
}
// Apply CSRF protection for state-changing operations
csrfProtection(req, res, (err) => {
if (err) {
return csrfErrorHandler(err, req, res, next);
}
injectCsrfToken(req, res, next);
});
};
/**
* Helper to get CSRF token for client-side use
*/
const getCsrfToken = (req, res) => {
res.json({
csrfToken: req.csrfToken()
});
};
module.exports = {
csrfProtection,
csrfErrorHandler,
injectCsrfToken,
conditionalCsrfProtection,
getCsrfToken
};

View File

@ -29,7 +29,16 @@
"express-session": "^1.17.3", "express-session": "^1.17.3",
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"qrcode": "^1.5.3" "qrcode": "^1.5.3",
"winston": "^3.11.0",
"winston-daily-rotate-file": "^4.7.1",
"compression": "^1.7.4",
"csurf": "^1.11.0",
"cookie-parser": "^1.4.6",
"bull": "^4.12.0",
"prom-client": "^15.1.0",
"sharp": "^0.33.0",
"ioredis": "^5.3.2"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.0.1", "nodemon": "^3.0.1",

View File

@ -130,6 +130,75 @@
border-color: rgba(40, 167, 69, 1); border-color: rgba(40, 167, 69, 1);
} }
.share-more-container {
position: relative;
display: inline-block;
}
.share-dropdown {
display: none;
position: absolute;
top: 100%;
right: 0;
margin-top: 0.5rem;
background: white;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
padding: 0.5rem;
z-index: 1000;
min-width: 200px;
}
.share-dropdown.show {
display: block;
}
.share-dropdown-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
}
.share-dropdown-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.75rem 0.5rem;
border-radius: 6px;
background: rgba(52, 152, 219, 0.1);
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
color: #2c3e50;
}
.share-dropdown-item:hover {
background: rgba(52, 152, 219, 0.2);
transform: translateY(-2px);
}
.share-dropdown-item svg {
width: 24px;
height: 24px;
margin-bottom: 0.25rem;
fill: #2c3e50;
}
.share-dropdown-item span {
font-size: 0.7rem;
text-align: center;
line-height: 1.2;
}
.share-btn-small.more-btn {
position: relative;
}
.share-btn-small.more-btn.active {
background: rgba(255, 255, 255, 0.4);
}
.campaign-content { .campaign-content {
max-width: 800px; max-width: 800px;
margin: 0 auto; margin: 0 auto;
@ -463,40 +532,110 @@
<!-- Social Share Buttons in Header --> <!-- Social Share Buttons in Header -->
<div class="share-buttons-header"> <div class="share-buttons-header">
<button class="share-btn-small" id="share-facebook" title="Share on Facebook"> <!-- Expandable Social Menu -->
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <div class="share-socials-container">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/> <button class="share-btn-primary" id="share-socials-toggle" title="Share on Socials">
</svg> <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class="share-icon">
</button> <path d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.16c-.05.21-.08.43-.08.65 0 1.61 1.31 2.92 2.92 2.92 1.61 0 2.92-1.31 2.92-2.92s-1.31-2.92-2.92-2.92z"/>
<button class="share-btn-small" id="share-twitter" title="Share on Twitter/X"> </svg>
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <span>Socials</span>
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/> <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class="chevron-icon">
</svg> <path d="M7 10l5 5 5-5z"/>
</button> </svg>
<button class="share-btn-small" id="share-linkedin" title="Share on LinkedIn"> </button>
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/> <!-- Expandable Social Options -->
</svg> <div class="share-socials-menu" id="share-socials-menu">
</button> <button class="share-btn-small" id="share-facebook" title="Facebook">
<button class="share-btn-small" id="share-whatsapp" title="Share on WhatsApp"> <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"/> </svg>
</svg> </button>
</button> <button class="share-btn-small" id="share-twitter" title="Twitter/X">
<button class="share-btn-small" id="share-email" title="Share via Email"> <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
<path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/> </svg>
</svg> </button>
</button> <button class="share-btn-small" id="share-linkedin" title="LinkedIn">
<button class="share-btn-small" id="share-copy" title="Copy Link"> <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
</svg>
</button>
<button class="share-btn-small" id="share-whatsapp" title="WhatsApp">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"/>
</svg>
</button>
<button class="share-btn-small" id="share-bluesky" title="Bluesky">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.206-.659-.298-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8Z"/>
</svg>
</button>
<button class="share-btn-small" id="share-instagram" title="Instagram">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M7.8 2h8.4C19.4 2 22 4.6 22 7.8v8.4a5.8 5.8 0 0 1-5.8 5.8H7.8C4.6 22 2 19.4 2 16.2V7.8A5.8 5.8 0 0 1 7.8 2m-.2 2A3.6 3.6 0 0 0 4 7.6v8.8C4 18.39 5.61 20 7.6 20h8.8a3.6 3.6 0 0 0 3.6-3.6V7.6C20 5.61 18.39 4 16.4 4H7.6m9.65 1.5a1.25 1.25 0 0 1 1.25 1.25A1.25 1.25 0 0 1 17.25 8 1.25 1.25 0 0 1 16 6.75a1.25 1.25 0 0 1 1.25-1.25M12 7a5 5 0 0 1 5 5 5 5 0 0 1-5 5 5 5 0 0 1-5-5 5 5 0 0 1 5-5m0 2a3 3 0 0 0-3 3 3 3 0 0 0 3 3 3 3 0 0 0 3-3 3 3 0 0 0-3-3z"/>
</svg>
</button>
<button class="share-btn-small" id="share-reddit" title="Reddit">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 0 0 .029-.463.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 0 0-.232-.095z"/>
</svg>
</button>
<button class="share-btn-small" id="share-threads" title="Threads">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M12.186 24h-.007c-3.581-.024-6.334-1.205-8.184-3.509C2.35 18.44 1.5 15.586 1.472 12.01v-.017c.03-3.579.879-6.43 2.525-8.482C5.845 1.205 8.6.024 12.18 0h.014c2.746.02 5.043.725 6.826 2.098 1.677 1.29 2.858 3.13 3.509 5.467l-2.04.569c-.542-1.947-1.499-3.488-2.846-4.576-1.488-1.2-3.457-1.806-5.854-1.826h-.01c-3.015.022-5.26.918-6.675 2.662C3.873 6.034 3.13 8.39 3.108 11.98v.014c.022 3.585.766 5.937 2.209 6.99 1.407 1.026 3.652 1.545 6.674 1.545h.01c.297 0 .597-.002.892-.009l.034 2.037c-.33.008-.665.012-1.001.012zM17.822 15.13v.002c-.184 1.376-.865 2.465-2.025 3.234-1.222.81-2.878 1.221-4.922 1.221-1.772 0-3.185-.34-4.197-1.009-.944-.625-1.488-1.527-1.617-2.68-.119-1.066.152-2.037.803-2.886.652-.85 1.595-1.464 2.802-1.823 1.102-.33 2.396-.495 3.847-.495h.343v1.615h-.343c-1.274 0-2.395.144-3.332.428-.937.284-1.653.713-2.129 1.275-.476.562-.664 1.229-.556 1.979.097.671.45 1.21 1.051 1.603.723.473 1.816.711 3.252.711 1.738 0 3.097-.35 4.042-.995.809-.552 1.348-1.349 1.603-2.373l1.98.193zM12.626 10.561v.002c-1.197 0-2.234.184-3.083.546-.938.4-1.668 1.017-2.169 1.835-.499.816-.748 1.792-.739 2.902l-2.037-.022c-.012-1.378.304-2.608.939-3.658.699-1.158 1.688-2.065 2.941-2.696 1.05-.527 2.274-.792 3.638-.792h.51v1.883h-.51z"/>
</svg>
</button>
<button class="share-btn-small" id="share-telegram" title="Telegram">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
</svg>
</button>
<button class="share-btn-small" id="share-mastodon" title="Mastodon">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/>
</svg>
</button>
<button class="share-btn-small" id="share-sms" title="SMS">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12zM7 9h2v2H7zm4 0h2v2h-2zm4 0h2v2h-2z"/>
</svg>
</button>
<button class="share-btn-small" id="share-slack" title="Slack">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.122 2.521a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.268 0a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zm-2.523 10.122a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zm0-1.268a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/>
</svg>
</button>
<button class="share-btn-small" id="share-discord" title="Discord">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>
</svg>
</button>
<button class="share-btn-small" id="share-print" title="Print">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M19 8H5c-1.66 0-3 1.34-3 3v6h4v4h12v-4h4v-6c0-1.66-1.34-3-3-3zm-3 11H8v-5h8v5zm3-7c-.55 0-1-.45-1-1s.45-1 1-1 1 .45 1 1-.45 1-1 1zm-1-9H6v4h12V3z"/>
</svg>
</button>
<button class="share-btn-small" id="share-email" title="Email">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/>
</svg>
</button>
</div>
</div>
<!-- Always-visible buttons -->
<button class="share-btn-primary" id="share-copy" title="Copy Link">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/> <path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
</svg> </svg>
<span>Copy Link</span>
</button> </button>
<button class="share-btn-small" id="share-qrcode" title="Show QR Code"> <button class="share-btn-primary" id="share-qrcode" title="Show QR Code">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M3 11h8V3H3v8zm2-6h4v4H5V5zM3 21h8v-8H3v8zm2-6h4v4H5v-4zM13 3v8h8V3h-8zm6 6h-4V5h4v4zM13 13h2v2h-2zM15 15h2v2h-2zM13 17h2v2h-2zM17 13h2v2h-2zM19 15h2v2h-2zM17 17h2v2h-2zM19 19h2v2h-2z"/> <path d="M3 11h8V3H3v8zm2-6h4v4H5V5zM3 21h8v-8H3v8zm2-6h4v4H5v-4zM13 3v8h8V3h-8zm6 6h-4V5h4v4zM13 13h2v2h-2zM15 15h2v2h-2zM13 17h2v2h-2zM17 13h2v2h-2zM19 15h2v2h-2zM17 17h2v2h-2zM19 19h2v2h-2z"/>
</svg> </svg>
<span>QR Code</span>
</button> </button>
</div> </div>
</div> </div>
@ -544,15 +683,6 @@
</form> </form>
</div> </div>
<!-- Response Wall Button -->
<div id="response-wall-section" class="response-wall-container" style="display: none;">
<h3>💬 See What People Are Saying</h3>
<p>Check out responses to people who have taken action on this campaign</p>
<a href="#" id="response-wall-link" class="response-wall-button">
View Response Wall
</a>
</div>
<!-- Email Preview --> <!-- Email Preview -->
<div id="email-preview" class="email-preview preview-mode" style="display: none;"> <div id="email-preview" class="email-preview preview-mode" style="display: none;">
<h3>📧 Email Preview</h3> <h3>📧 Email Preview</h3>
@ -599,6 +729,15 @@
</div> </div>
</div> </div>
<!-- Response Wall Button -->
<div id="response-wall-section" class="response-wall-container" style="display: none;">
<h3>💬 See What People Are Saying</h3>
<p>Check out responses to people who have taken action on this campaign</p>
<a href="#" id="response-wall-link" class="response-wall-button">
View Response Wall
</a>
</div>
<!-- Success Message --> <!-- Success Message -->
<div id="success-section" style="display: none; text-align: center; padding: 2rem;"> <div id="success-section" style="display: none; text-align: center; padding: 2rem;">
<h2 style="color: #27ae60;">🎉 Thank you for taking action!</h2> <h2 style="color: #27ae60;">🎉 Thank you for taking action!</h2>

View File

@ -98,10 +98,185 @@
/* Social Share Buttons in Header */ /* Social Share Buttons in Header */
.share-buttons-header { .share-buttons-header {
display: flex; display: flex;
gap: 0.5rem; gap: 0.75rem;
justify-content: center; justify-content: center;
margin-top: 1rem; margin-top: 1rem;
flex-wrap: wrap; flex-wrap: wrap;
align-items: flex-start;
}
/* Primary Share Buttons */
.share-btn-primary {
background: rgba(255, 255, 255, 0.2);
border: 2px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 0.75rem 1.25rem;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
backdrop-filter: blur(10px);
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
}
.share-btn-primary:hover {
background: rgba(255, 255, 255, 0.35);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.share-btn-primary svg {
width: 20px;
height: 20px;
fill: white;
}
.share-btn-primary.copied {
background: rgba(40, 167, 69, 0.9);
border-color: rgba(40, 167, 69, 1);
}
/* Expandable Social Menu Container */
.share-socials-container {
position: relative;
display: inline-block;
}
.share-socials-menu {
position: absolute;
top: calc(100% + 0.5rem);
left: 50%;
transform: translateX(-50%);
background: white;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
padding: 0.75rem;
display: none;
flex-wrap: wrap;
gap: 0.5rem;
z-index: 1000;
min-width: 280px;
max-width: 320px;
opacity: 0;
transform: translateX(-50%) translateY(-10px);
transition: opacity 0.3s ease, transform 0.3s ease;
}
.share-socials-menu.show {
display: flex;
opacity: 1;
transform: translateX(-50%) translateY(0);
}
/* Chevron icon rotation */
.share-btn-primary .chevron-icon {
width: 16px;
height: 16px;
fill: white;
transition: transform 0.3s ease;
}
.share-btn-primary.active .chevron-icon {
transform: rotate(180deg);
}
/* Share icon */
.share-btn-primary .share-icon {
width: 20px;
height: 20px;
fill: white;
}
/* Social buttons inside menu */
.share-socials-menu button {
border: none;
border-radius: 8px;
width: 42px;
height: 42px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
padding: 0;
color: white;
}
.share-socials-menu button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
filter: brightness(1.1);
}
.share-socials-menu button svg {
width: 22px;
height: 22px;
fill: white;
}
/* Platform-specific colors */
#share-facebook {
background: #1877f2;
}
#share-twitter {
background: #000000;
}
#share-linkedin {
background: #0077b5;
}
#share-whatsapp {
background: #25d366;
}
#share-bluesky {
background: #1185fe;
}
#share-instagram {
background: linear-gradient(45deg, #f09433 0%, #e6683c 25%, #dc2743 50%, #cc2366 75%, #bc1888 100%);
}
#share-reddit {
background: #ff4500;
}
#share-threads {
background: #000000;
}
#share-telegram {
background: #0088cc;
}
#share-mastodon {
background: #6364ff;
}
#share-sms {
background: #34c759;
}
#share-slack {
background: #4a154b;
}
#share-discord {
background: #5865f2;
}
#share-print {
background: #6c757d;
}
#share-email {
background: #ea4335;
} }
.share-btn-small { .share-btn-small {

View File

@ -1686,6 +1686,17 @@ footer a:hover {
.campaigns-section-header { .campaigns-section-header {
text-align: center; text-align: center;
margin-bottom: 40px; margin-bottom: 40px;
opacity: 0;
animation: fadeIn 0.8s ease forwards;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
} }
.campaigns-section-header h2 { .campaigns-section-header h2 {
@ -1717,8 +1728,37 @@ footer a:hover {
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
outline: none; outline: none;
opacity: 0;
transform: translateY(30px);
animation: campaignFadeInUp 0.6s ease forwards;
} }
@keyframes campaignFadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Staggered animation delays for campaign cards */
.campaign-card:nth-child(1) { animation-delay: 0.1s; }
.campaign-card:nth-child(2) { animation-delay: 0.2s; }
.campaign-card:nth-child(3) { animation-delay: 0.3s; }
.campaign-card:nth-child(4) { animation-delay: 0.4s; }
.campaign-card:nth-child(5) { animation-delay: 0.5s; }
.campaign-card:nth-child(6) { animation-delay: 0.6s; }
.campaign-card:nth-child(7) { animation-delay: 0.7s; }
.campaign-card:nth-child(8) { animation-delay: 0.8s; }
.campaign-card:nth-child(9) { animation-delay: 0.9s; }
.campaign-card:nth-child(10) { animation-delay: 1.0s; }
.campaign-card:nth-child(11) { animation-delay: 1.1s; }
.campaign-card:nth-child(12) { animation-delay: 1.2s; }
/* For campaigns beyond 12, they'll still animate but without additional delay */
.campaign-card:hover, .campaign-card:hover,
.campaign-card:focus { .campaign-card:focus {
transform: translateY(-5px); transform: translateY(-5px);
@ -1735,6 +1775,33 @@ footer a:hover {
height: 200px; height: 200px;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.campaign-card-image::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: inherit;
transform: scale(1.1);
animation: imageZoomIn 0.8s ease forwards;
animation-delay: inherit;
z-index: 0;
}
@keyframes imageZoomIn {
from {
transform: scale(1.2);
}
to {
transform: scale(1);
}
} }
.campaign-card-overlay { .campaign-card-overlay {
@ -1743,12 +1810,13 @@ footer a:hover {
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background: rgba(0, 0, 0, 0.3); background: linear-gradient(to bottom, rgba(0, 0, 0, 0.1) 0%, rgba(0, 0, 0, 0.7) 100%);
transition: background 0.3s ease; transition: background 0.3s ease;
z-index: 1;
} }
.campaign-card:hover .campaign-card-overlay { .campaign-card:hover .campaign-card-overlay {
background: rgba(0, 0, 0, 0.4); background: linear-gradient(to bottom, rgba(0, 0, 0, 0.2) 0%, rgba(0, 0, 0, 0.8) 100%);
} }
.campaign-card-content { .campaign-card-content {
@ -1759,11 +1827,22 @@ footer a:hover {
} }
.campaign-card-title { .campaign-card-title {
color: #005a9c; color: #ffffff;
font-size: 1.4em; font-size: 1.5em;
margin-bottom: 12px; margin: 0;
font-weight: 600; font-weight: 700;
line-height: 1.3; line-height: 1.3;
text-align: center;
text-shadow: 0 2px 8px rgba(0, 0, 0, 0.5), 0 4px 16px rgba(0, 0, 0, 0.3);
position: relative;
z-index: 2;
width: 100%;
transition: transform 0.3s ease, text-shadow 0.3s ease;
}
.campaign-card:hover .campaign-card-title {
transform: translateY(-2px);
text-shadow: 0 3px 12px rgba(0, 0, 0, 0.6), 0 6px 20px rgba(0, 0, 0, 0.4);
} }
.campaign-card-description { .campaign-card-description {
@ -1771,6 +1850,7 @@ footer a:hover {
font-size: 0.95em; font-size: 0.95em;
line-height: 1.6; line-height: 1.6;
margin-bottom: 16px; margin-bottom: 16px;
margin-top: 0;
flex: 1; flex: 1;
} }
@ -1960,6 +2040,8 @@ footer a:hover {
text-align: center; text-align: center;
padding: 60px 20px; padding: 60px 20px;
color: #666; color: #666;
opacity: 0;
animation: fadeIn 0.5s ease forwards;
} }
.campaigns-loading .spinner { .campaigns-loading .spinner {
@ -2142,3 +2224,187 @@ footer a:hover {
font-size: 1.25rem; font-size: 1.25rem;
} }
} }
/* Expandable Social Share Menu Styles */
.share-buttons-header {
display: flex;
gap: 0.75rem;
justify-content: center;
margin-top: 1rem;
flex-wrap: wrap;
align-items: flex-start;
}
/* Primary Share Buttons */
.share-btn-primary {
background: rgba(255, 255, 255, 0.2);
border: 2px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 0.75rem 1.25rem;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
backdrop-filter: blur(10px);
display: inline-flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
}
.share-btn-primary:hover {
background: rgba(255, 255, 255, 0.35);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.share-btn-primary svg {
width: 20px;
height: 20px;
fill: white;
}
.share-btn-primary.copied {
background: rgba(40, 167, 69, 0.9);
border-color: rgba(40, 167, 69, 1);
}
/* Expandable Social Menu Container */
.share-socials-container {
position: relative;
display: inline-block;
}
.share-socials-menu {
position: absolute;
top: calc(100% + 0.5rem);
left: 50%;
transform: translateX(-50%);
background: white;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
padding: 0.75rem;
display: none;
flex-wrap: wrap;
gap: 0.5rem;
z-index: 1000;
min-width: 280px;
max-width: 320px;
opacity: 0;
transform: translateX(-50%) translateY(-10px);
transition: opacity 0.3s ease, transform 0.3s ease;
}
.share-socials-menu.show {
display: flex;
opacity: 1;
transform: translateX(-50%) translateY(0);
}
/* Chevron icon rotation */
.share-btn-primary .chevron-icon {
width: 16px;
height: 16px;
fill: white;
transition: transform 0.3s ease;
}
.share-btn-primary.active .chevron-icon {
transform: rotate(180deg);
}
/* Share icon */
.share-btn-primary .share-icon {
width: 20px;
height: 20px;
fill: white;
}
/* Social buttons inside menu */
.share-socials-menu button {
border: none;
border-radius: 8px;
width: 42px;
height: 42px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
padding: 0;
color: white;
}
.share-socials-menu button:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
filter: brightness(1.1);
}
.share-socials-menu button svg {
width: 22px;
height: 22px;
fill: white;
}
/* Platform-specific colors */
#share-facebook {
background: #1877f2;
}
#share-twitter {
background: #000000;
}
#share-linkedin {
background: #0077b5;
}
#share-whatsapp {
background: #25d366;
}
#share-bluesky {
background: #1185fe;
}
#share-instagram {
background: linear-gradient(45deg, #f09433 0%, #e6683c 25%, #dc2743 50%, #cc2366 75%, #bc1888 100%);
}
#share-reddit {
background: #ff4500;
}
#share-threads {
background: #000000;
}
#share-telegram {
background: #0088cc;
}
#share-mastodon {
background: #6364ff;
}
#share-sms {
background: #34c759;
}
#share-slack {
background: #4a154b;
}
#share-discord {
background: #5865f2;
}
#share-print {
background: #6c757d;
}
#share-email {
background: #ea4335;
}

View File

@ -33,7 +33,27 @@ class CampaignPage {
setupShareButtons() { setupShareButtons() {
// Get current URL // Get current URL
const shareUrl = window.location.href; const shareUrl = window.location.href;
// Social menu toggle
const socialsToggle = document.getElementById('share-socials-toggle');
const socialsMenu = document.getElementById('share-socials-menu');
if (socialsToggle && socialsMenu) {
socialsToggle.addEventListener('click', (e) => {
e.stopPropagation();
socialsMenu.classList.toggle('show');
socialsToggle.classList.toggle('active');
});
// Close menu when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('.share-socials-container')) {
socialsMenu.classList.remove('show');
socialsToggle.classList.remove('active');
}
});
}
// Facebook share // Facebook share
document.getElementById('share-facebook')?.addEventListener('click', () => { document.getElementById('share-facebook')?.addEventListener('click', () => {
const url = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`; const url = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`;
@ -60,6 +80,93 @@ class CampaignPage {
window.open(url, '_blank'); window.open(url, '_blank');
}); });
// Bluesky share
document.getElementById('share-bluesky')?.addEventListener('click', () => {
const text = this.campaign ? `Check out this campaign: ${this.campaign.title}` : 'Check out this campaign';
const url = `https://bsky.app/intent/compose?text=${encodeURIComponent(text + ' ' + shareUrl)}`;
window.open(url, '_blank', 'width=600,height=400');
});
// Instagram share (note: Instagram doesn't have direct web share, opens Instagram web)
document.getElementById('share-instagram')?.addEventListener('click', () => {
alert('To share on Instagram:\n1. Copy the link (use the copy button)\n2. Open Instagram app\n3. Create a post or story\n4. Paste the link in your caption');
// Automatically copy the link
navigator.clipboard.writeText(shareUrl).catch(() => {
console.log('Failed to copy link automatically');
});
});
// Reddit share
document.getElementById('share-reddit')?.addEventListener('click', () => {
const title = this.campaign ? `${this.campaign.title}` : 'Check out this campaign';
const url = `https://www.reddit.com/submit?url=${encodeURIComponent(shareUrl)}&title=${encodeURIComponent(title)}`;
window.open(url, '_blank', 'width=800,height=600');
});
// Threads share
document.getElementById('share-threads')?.addEventListener('click', () => {
const text = this.campaign ? `Check out this campaign: ${this.campaign.title}` : 'Check out this campaign';
const url = `https://threads.net/intent/post?text=${encodeURIComponent(text + ' ' + shareUrl)}`;
window.open(url, '_blank', 'width=600,height=400');
});
// Telegram share
document.getElementById('share-telegram')?.addEventListener('click', () => {
const text = this.campaign ? `Check out this campaign: ${this.campaign.title}` : 'Check out this campaign';
const url = `https://t.me/share/url?url=${encodeURIComponent(shareUrl)}&text=${encodeURIComponent(text)}`;
window.open(url, '_blank', 'width=600,height=400');
});
// Mastodon share
document.getElementById('share-mastodon')?.addEventListener('click', () => {
const text = this.campaign ? `Check out this campaign: ${this.campaign.title}` : 'Check out this campaign';
// Mastodon requires instance selection - opens a composer with text
const instance = prompt('Enter your Mastodon instance (e.g., mastodon.social):');
if (instance) {
const url = `https://${instance}/share?text=${encodeURIComponent(text + ' ' + shareUrl)}`;
window.open(url, '_blank', 'width=600,height=400');
}
});
// SMS share
document.getElementById('share-sms')?.addEventListener('click', () => {
const text = this.campaign ? `Check out this campaign: ${this.campaign.title}` : 'Check out this campaign';
const body = text + ' ' + shareUrl;
// Use Web Share API if available, otherwise fallback to SMS protocol
if (navigator.share) {
navigator.share({
title: this.campaign ? this.campaign.title : 'Campaign',
text: body
}).catch(() => {
// Fallback to SMS protocol
window.location.href = `sms:?&body=${encodeURIComponent(body)}`;
});
} else {
// SMS protocol (works on mobile)
window.location.href = `sms:?&body=${encodeURIComponent(body)}`;
}
});
// Slack share
document.getElementById('share-slack')?.addEventListener('click', () => {
const url = `https://slack.com/intl/en-ca/share?url=${encodeURIComponent(shareUrl)}`;
window.open(url, '_blank', 'width=600,height=400');
});
// Discord share
document.getElementById('share-discord')?.addEventListener('click', () => {
alert('To share on Discord:\n1. Copy the link (use the copy button)\n2. Open Discord\n3. Paste the link in any channel or DM\n\nDiscord will automatically create a preview!');
// Automatically copy the link
navigator.clipboard.writeText(shareUrl).catch(() => {
console.log('Failed to copy link automatically');
});
});
// Print/PDF share
document.getElementById('share-print')?.addEventListener('click', () => {
window.print();
});
// Email share // Email share
document.getElementById('share-email')?.addEventListener('click', () => { document.getElementById('share-email')?.addEventListener('click', () => {
const subject = this.campaign ? `Campaign: ${this.campaign.title}` : 'Check out this campaign'; const subject = this.campaign ? `Campaign: ${this.campaign.title}` : 'Check out this campaign';

View File

@ -72,11 +72,27 @@ class CampaignsGrid {
const campaignsHTML = sortedCampaigns.map(campaign => this.renderCampaignCard(campaign)).join(''); const campaignsHTML = sortedCampaigns.map(campaign => this.renderCampaignCard(campaign)).join('');
this.container.innerHTML = campaignsHTML; this.container.innerHTML = campaignsHTML;
// Trigger animations by forcing a reflow
this.triggerAnimations();
// Add click event listeners to campaign cards (no inline handlers) // Add click event listeners to campaign cards (no inline handlers)
this.attachCardClickHandlers(); this.attachCardClickHandlers();
} }
triggerAnimations() {
// Force reflow to restart CSS animations when campaigns are re-rendered
const cards = this.container.querySelectorAll('.campaign-card');
cards.forEach((card, index) => {
// Remove and re-add animation to restart it
card.style.animation = 'none';
// Force reflow
void card.offsetHeight;
// Re-apply animation with staggered delay
card.style.animation = `campaignFadeInUp 0.6s ease ${0.1 * (index + 1)}s forwards`;
});
}
attachCardClickHandlers() { attachCardClickHandlers() {
const campaignCards = this.container.querySelectorAll('.campaign-card'); const campaignCards = this.container.querySelectorAll('.campaign-card');
campaignCards.forEach(card => { campaignCards.forEach(card => {
@ -157,9 +173,9 @@ class CampaignsGrid {
<div class="campaign-card" data-slug="${campaign.slug}"> <div class="campaign-card" data-slug="${campaign.slug}">
<div class="campaign-card-image" style="${coverPhotoStyle}"> <div class="campaign-card-image" style="${coverPhotoStyle}">
<div class="campaign-card-overlay"></div> <div class="campaign-card-overlay"></div>
<h3 class="campaign-card-title">${this.escapeHtml(campaign.title)}</h3>
</div> </div>
<div class="campaign-card-content"> <div class="campaign-card-content">
<h3 class="campaign-card-title">${this.escapeHtml(campaign.title)}</h3>
<p class="campaign-card-description">${this.escapeHtml(truncatedDescription)}</p> <p class="campaign-card-description">${this.escapeHtml(truncatedDescription)}</p>
${targetLevels ? `<div class="campaign-card-levels">${targetLevels}</div>` : ''} ${targetLevels ? `<div class="campaign-card-levels">${targetLevels}</div>` : ''}
<div class="campaign-card-stats"> <div class="campaign-card-stats">

View File

@ -342,7 +342,27 @@ function renderCampaignHeader() {
// Setup social share buttons // Setup social share buttons
function setupShareButtons() { function setupShareButtons() {
const shareUrl = window.location.href; const shareUrl = window.location.href;
// Social menu toggle
const socialsToggle = document.getElementById('share-socials-toggle');
const socialsMenu = document.getElementById('share-socials-menu');
if (socialsToggle && socialsMenu) {
socialsToggle.addEventListener('click', (e) => {
e.stopPropagation();
socialsMenu.classList.toggle('show');
socialsToggle.classList.toggle('active');
});
// Close menu when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('.share-socials-container')) {
socialsMenu.classList.remove('show');
socialsToggle.classList.remove('active');
}
});
}
// Facebook share // Facebook share
document.getElementById('share-facebook')?.addEventListener('click', () => { document.getElementById('share-facebook')?.addEventListener('click', () => {
const url = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`; const url = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`;
@ -369,6 +389,93 @@ function setupShareButtons() {
window.open(url, '_blank'); window.open(url, '_blank');
}); });
// Bluesky share
document.getElementById('share-bluesky')?.addEventListener('click', () => {
const text = currentCampaign ? `Check out responses from representatives for: ${currentCampaign.title}` : 'Check out representative responses';
const url = `https://bsky.app/intent/compose?text=${encodeURIComponent(text + ' ' + shareUrl)}`;
window.open(url, '_blank', 'width=600,height=400');
});
// Instagram share (note: Instagram doesn't have direct web share, opens Instagram web)
document.getElementById('share-instagram')?.addEventListener('click', () => {
alert('To share on Instagram:\n1. Copy the link (use the copy button)\n2. Open Instagram app\n3. Create a post or story\n4. Paste the link in your caption');
// Automatically copy the link
navigator.clipboard.writeText(shareUrl).catch(() => {
console.log('Failed to copy link automatically');
});
});
// Reddit share
document.getElementById('share-reddit')?.addEventListener('click', () => {
const title = currentCampaign ? `Representative Responses: ${currentCampaign.title}` : 'Check out these representative responses';
const url = `https://www.reddit.com/submit?url=${encodeURIComponent(shareUrl)}&title=${encodeURIComponent(title)}`;
window.open(url, '_blank', 'width=800,height=600');
});
// Threads share
document.getElementById('share-threads')?.addEventListener('click', () => {
const text = currentCampaign ? `Check out responses from representatives for: ${currentCampaign.title}` : 'Check out representative responses';
const url = `https://threads.net/intent/post?text=${encodeURIComponent(text + ' ' + shareUrl)}`;
window.open(url, '_blank', 'width=600,height=400');
});
// Telegram share
document.getElementById('share-telegram')?.addEventListener('click', () => {
const text = currentCampaign ? `Check out responses from representatives for: ${currentCampaign.title}` : 'Check out representative responses';
const url = `https://t.me/share/url?url=${encodeURIComponent(shareUrl)}&text=${encodeURIComponent(text)}`;
window.open(url, '_blank', 'width=600,height=400');
});
// Mastodon share
document.getElementById('share-mastodon')?.addEventListener('click', () => {
const text = currentCampaign ? `Check out responses from representatives for: ${currentCampaign.title}` : 'Check out representative responses';
// Mastodon requires instance selection - opens a composer with text
const instance = prompt('Enter your Mastodon instance (e.g., mastodon.social):');
if (instance) {
const url = `https://${instance}/share?text=${encodeURIComponent(text + ' ' + shareUrl)}`;
window.open(url, '_blank', 'width=600,height=400');
}
});
// SMS share
document.getElementById('share-sms')?.addEventListener('click', () => {
const text = currentCampaign ? `Check out responses from representatives for: ${currentCampaign.title}` : 'Check out representative responses';
const body = text + ' ' + shareUrl;
// Use Web Share API if available, otherwise fallback to SMS protocol
if (navigator.share) {
navigator.share({
title: currentCampaign ? currentCampaign.title : 'Response Wall',
text: body
}).catch(() => {
// Fallback to SMS protocol
window.location.href = `sms:?&body=${encodeURIComponent(body)}`;
});
} else {
// SMS protocol (works on mobile)
window.location.href = `sms:?&body=${encodeURIComponent(body)}`;
}
});
// Slack share
document.getElementById('share-slack')?.addEventListener('click', () => {
const url = `https://slack.com/intl/en-ca/share?url=${encodeURIComponent(shareUrl)}`;
window.open(url, '_blank', 'width=600,height=400');
});
// Discord share
document.getElementById('share-discord')?.addEventListener('click', () => {
alert('To share on Discord:\n1. Copy the link (use the copy button)\n2. Open Discord\n3. Paste the link in any channel or DM\n\nDiscord will automatically create a preview!');
// Automatically copy the link
navigator.clipboard.writeText(shareUrl).catch(() => {
console.log('Failed to copy link automatically');
});
});
// Print/PDF share
document.getElementById('share-print')?.addEventListener('click', () => {
window.print();
});
// Email share // Email share
document.getElementById('share-email')?.addEventListener('click', () => { document.getElementById('share-email')?.addEventListener('click', () => {
const subject = currentCampaign ? `Representative Responses: ${currentCampaign.title}` : 'Check out these representative responses'; const subject = currentCampaign ? `Representative Responses: ${currentCampaign.title}` : 'Check out these representative responses';

View File

@ -26,40 +26,80 @@
<!-- Social Share Buttons in Header --> <!-- Social Share Buttons in Header -->
<div class="share-buttons-header"> <div class="share-buttons-header">
<button class="share-btn-small" id="share-facebook" title="Share on Facebook"> <!-- Expandable Social Menu -->
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <div class="share-socials-container">
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/> <button class="share-btn-primary" id="share-socials-toggle" title="Share on Socials">
</svg> <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class="share-icon">
</button> <path d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.16c-.05.21-.08.43-.08.65 0 1.61 1.31 2.92 2.92 2.92 1.61 0 2.92-1.31 2.92-2.92s-1.31-2.92-2.92-2.92z"/>
<button class="share-btn-small" id="share-twitter" title="Share on Twitter/X"> </svg>
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <span>Socials</span>
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/> <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class="chevron-icon">
</svg> <path d="M7 10l5 5 5-5z"/>
</button> </svg>
<button class="share-btn-small" id="share-linkedin" title="Share on LinkedIn"> </button>
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/> <!-- Expandable Social Options -->
</svg> <div class="share-socials-menu" id="share-socials-menu">
</button> <button class="share-btn-small" id="share-facebook" title="Facebook">
<button class="share-btn-small" id="share-whatsapp" title="Share on WhatsApp"> <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/></svg>
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> </button>
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"/> <button class="share-btn-small" id="share-twitter" title="Twitter/X">
</svg> <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/></svg>
</button> </button>
<button class="share-btn-small" id="share-email" title="Share via Email"> <button class="share-btn-small" id="share-linkedin" title="LinkedIn">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
<path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/> </button>
</svg> <button class="share-btn-small" id="share-whatsapp" title="WhatsApp">
</button> <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"/></svg>
<button class="share-btn-small" id="share-copy" title="Copy Link"> </button>
<button class="share-btn-small" id="share-bluesky" title="Bluesky">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.206-.659-.298-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8Z"/></svg>
</button>
<button class="share-btn-small" id="share-instagram" title="Instagram">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M7.8 2h8.4C19.4 2 22 4.6 22 7.8v8.4a5.8 5.8 0 0 1-5.8 5.8H7.8C4.6 22 2 19.4 2 16.2V7.8A5.8 5.8 0 0 1 7.8 2m-.2 2A3.6 3.6 0 0 0 4 7.6v8.8C4 18.39 5.61 20 7.6 20h8.8a3.6 3.6 0 0 0 3.6-3.6V7.6C20 5.61 18.39 4 16.4 4H7.6m9.65 1.5a1.25 1.25 0 0 1 1.25 1.25A1.25 1.25 0 0 1 17.25 8 1.25 1.25 0 0 1 16 6.75a1.25 1.25 0 0 1 1.25-1.25M12 7a5 5 0 0 1 5 5 5 5 0 0 1-5 5 5 5 0 0 1-5-5 5 5 0 0 1 5-5m0 2a3 3 0 0 0-3 3 3 3 0 0 0 3 3 3 3 0 0 0 3-3 3 3 0 0 0-3-3z"/></svg>
</button>
<button class="share-btn-small" id="share-reddit" title="Reddit">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 0 0 .029-.463.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 0 0-.232-.095z"/></svg>
</button>
<button class="share-btn-small" id="share-threads" title="Threads">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M12.186 24h-.007c-3.581-.024-6.334-1.205-8.184-3.509C2.35 18.44 1.5 15.586 1.472 12.01v-.017c.03-3.579.879-6.43 2.525-8.482C5.845 1.205 8.6.024 12.18 0h.014c2.746.02 5.043.725 6.826 2.098 1.677 1.29 2.858 3.13 3.509 5.467l-2.04.569c-.542-1.947-1.499-3.488-2.846-4.576-1.488-1.2-3.457-1.806-5.854-1.826h-.01c-3.015.022-5.26.918-6.675 2.662C3.873 6.034 3.13 8.39 3.108 11.98v.014c.022 3.585.766 5.937 2.209 6.99 1.407 1.026 3.652 1.545 6.674 1.545h.01c.297 0 .597-.002.892-.009l.034 2.037c-.33.008-.665.012-1.001.012zM17.822 15.13v.002c-.184 1.376-.865 2.465-2.025 3.234-1.222.81-2.878 1.221-4.922 1.221-1.772 0-3.185-.34-4.197-1.009-.944-.625-1.488-1.527-1.617-2.68-.119-1.066.152-2.037.803-2.886.652-.85 1.595-1.464 2.802-1.823 1.102-.33 2.396-.495 3.847-.495h.343v1.615h-.343c-1.274 0-2.395.144-3.332.428-.937.284-1.653.713-2.129 1.275-.476.562-.664 1.229-.556 1.979.097.671.45 1.21 1.051 1.603.723.473 1.816.711 3.252.711 1.738 0 3.097-.35 4.042-.995.809-.552 1.348-1.349 1.603-2.373l1.98.193zM12.626 10.561v.002c-1.197 0-2.234.184-3.083.546-.938.4-1.668 1.017-2.169 1.835-.499.816-.748 1.792-.739 2.902l-2.037-.022c-.012-1.378.304-2.608.939-3.658.699-1.158 1.688-2.065 2.941-2.696 1.05-.527 2.274-.792 3.638-.792h.51v1.883h-.51z"/></svg>
</button>
<button class="share-btn-small" id="share-telegram" title="Telegram">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/></svg>
</button>
<button class="share-btn-small" id="share-mastodon" title="Mastodon">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/></svg>
</button>
<button class="share-btn-small" id="share-sms" title="SMS">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12zM7 9h2v2H7zm4 0h2v2h-2zm4 0h2v2h-2z"/></svg>
</button>
<button class="share-btn-small" id="share-slack" title="Slack">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.122 2.521a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.268 0a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zm-2.523 10.122a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zm0-1.268a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/></svg>
</button>
<button class="share-btn-small" id="share-discord" title="Discord">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
</button>
<button class="share-btn-small" id="share-print" title="Print">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M19 8H5c-1.66 0-3 1.34-3 3v6h4v4h12v-4h4v-6c0-1.66-1.34-3-3-3zm-3 11H8v-5h8v5zm3-7c-.55 0-1-.45-1-1s.45-1 1-1 1 .45 1 1-.45 1-1 1zm-1-9H6v4h12V3z"/></svg>
</button>
<button class="share-btn-small" id="share-email" title="Email">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/></svg>
</button>
</div>
</div>
<!-- Always-visible buttons -->
<button class="share-btn-primary" id="share-copy" title="Copy Link">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/> <path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
</svg> </svg>
<span>Copy Link</span>
</button> </button>
<button class="share-btn-small" id="share-qrcode" title="Show QR Code"> <button class="share-btn-primary" id="share-qrcode" title="Show QR Code">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M3 11h8V3H3v8zm2-6h4v4H5V5zM3 21h8v-8H3v8zm2-6h4v4H5v-4zM13 3v8h8V3h-8zm6 6h-4V5h4v4zM13 13h2v2h-2zM15 15h2v2h-2zM13 17h2v2h-2zM17 13h2v2h-2zM19 15h2v2h-2zM17 17h2v2h-2zM19 19h2v2h-2z"/> <path d="M3 11h8V3H3v8zm2-6h4v4H5V5zM3 21h8v-8H3v8zm2-6h4v4H5v-4zM13 3v8h8V3h-8zm6 6h-4V5h4v4zM13 13h2v2h-2zM15 15h2v2h-2zM13 17h2v2h-2zM17 13h2v2h-2zM19 15h2v2h-2zM17 17h2v2h-2zM19 19h2v2h-2z"/>
</svg> </svg>
<span>QR Code</span>
</button> </button>
</div> </div>
</div> </div>

View File

@ -2,9 +2,16 @@ const express = require('express');
const cors = require('cors'); const cors = require('cors');
const helmet = require('helmet'); const helmet = require('helmet');
const session = require('express-session'); const session = require('express-session');
const compression = require('compression');
const cookieParser = require('cookie-parser');
const path = require('path'); const path = require('path');
require('dotenv').config(); require('dotenv').config();
const logger = require('./utils/logger');
const metrics = require('./utils/metrics');
const healthCheck = require('./utils/health-check');
const { conditionalCsrfProtection, getCsrfToken } = require('./middleware/csrf');
const apiRoutes = require('./routes/api'); const apiRoutes = require('./routes/api');
const authRoutes = require('./routes/auth'); const authRoutes = require('./routes/auth');
const { requireAdmin, requireAuth } = require('./middleware/auth'); const { requireAdmin, requireAuth } = require('./middleware/auth');
@ -16,6 +23,17 @@ const PORT = process.env.PORT || 3333;
// Only trust Docker internal networks for better security // Only trust Docker internal networks for better security
app.set('trust proxy', ['127.0.0.1', '::1', '172.16.0.0/12', '192.168.0.0/16', '10.0.0.0/8']); app.set('trust proxy', ['127.0.0.1', '::1', '172.16.0.0/12', '192.168.0.0/16', '10.0.0.0/8']);
// Compression middleware for better performance
app.use(compression({
filter: (req, res) => {
if (req.headers['x-no-compression']) {
return false;
}
return compression.filter(req, res);
},
level: 6 // Balance between speed and compression ratio
}));
// Security middleware // Security middleware
app.use(helmet({ app.use(helmet({
contentSecurityPolicy: { contentSecurityPolicy: {
@ -31,22 +49,89 @@ app.use(helmet({
// Middleware // Middleware
app.use(cors()); app.use(cors());
app.use(express.json()); app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true })); app.use(express.urlencoded({ extended: true, limit: '10mb' }));
app.use(cookieParser());
// Session configuration // Metrics middleware - track all HTTP requests
app.use(metrics.middleware);
// Request logging middleware
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
logger.logRequest(req, res, duration);
});
next();
});
// Session configuration - PRODUCTION HARDENED
app.use(session({ app.use(session({
secret: process.env.SESSION_SECRET || 'influence-campaign-secret-key-change-in-production', secret: process.env.SESSION_SECRET || 'influence-campaign-secret-key-change-in-production',
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,
name: 'influence.sid', // Custom session name for security
cookie: { cookie: {
secure: process.env.NODE_ENV === 'production' && process.env.HTTPS === 'true', secure: process.env.NODE_ENV === 'production' && process.env.HTTPS === 'true', // HTTPS only in production
httpOnly: true, httpOnly: true, // Prevent JavaScript access
maxAge: 24 * 60 * 60 * 1000 // 24 hours maxAge: 3600000, // 1 hour (reduced from 24 hours)
sameSite: 'strict' // CSRF protection
} }
})); }));
app.use(express.static(path.join(__dirname, 'public'))); // CSRF Protection - Applied conditionally
app.use(conditionalCsrfProtection);
// Static files with proper caching
app.use(express.static(path.join(__dirname, 'public'), {
maxAge: process.env.NODE_ENV === 'production' ? '1d' : 0,
etag: true,
lastModified: true,
setHeaders: (res, filePath) => {
// Cache images and assets longer
if (filePath.match(/\.(jpg|jpeg|png|gif|ico|svg|woff|woff2|ttf|eot)$/)) {
res.setHeader('Cache-Control', 'public, max-age=604800'); // 7 days
}
// Cache CSS and JS for 1 day
else if (filePath.match(/\.(css|js)$/)) {
res.setHeader('Cache-Control', 'public, max-age=86400'); // 1 day
}
}
}));
// Health check endpoint - COMPREHENSIVE
app.get('/api/health', async (req, res) => {
try {
const health = await healthCheck.checkAll();
const statusCode = health.status === 'healthy' ? 200 : health.status === 'degraded' ? 200 : 503;
res.status(statusCode).json(health);
} catch (error) {
logger.error('Health check failed', { error: error.message });
res.status(503).json({
status: 'unhealthy',
error: error.message,
timestamp: new Date().toISOString()
});
}
});
// Metrics endpoint for Prometheus
app.get('/api/metrics', async (req, res) => {
try {
res.set('Content-Type', metrics.getContentType());
const metricsData = await metrics.getMetrics();
res.end(metricsData);
} catch (error) {
logger.error('Metrics endpoint failed', { error: error.message });
res.status(500).json({ error: 'Failed to generate metrics' });
}
});
// CSRF token endpoint
app.get('/api/csrf-token', getCsrfToken);
// Routes // Routes
app.use('/api/auth', authRoutes); app.use('/api/auth', authRoutes);
@ -100,21 +185,54 @@ app.get('/campaign/:slug', (req, res) => {
// Error handling middleware // Error handling middleware
app.use((err, req, res, next) => { app.use((err, req, res, next) => {
console.error(err.stack); logger.error('Application error', {
res.status(500).json({ error: err.message,
stack: err.stack,
path: req.path,
method: req.method,
ip: req.ip
});
res.status(err.status || 500).json({
error: 'Something went wrong!', error: 'Something went wrong!',
message: process.env.NODE_ENV === 'development' ? err.message : 'Internal server error' message: process.env.NODE_ENV === 'development' ? err.message : 'Internal server error',
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
}); });
}); });
// 404 handler // 404 handler
app.use((req, res) => { app.use((req, res) => {
logger.warn('Route not found', {
path: req.path,
method: req.method,
ip: req.ip
});
res.status(404).json({ error: 'Route not found' }); res.status(404).json({ error: 'Route not found' });
}); });
app.listen(PORT, () => { // Graceful shutdown
console.log(`Server running on port ${PORT}`); process.on('SIGTERM', () => {
console.log(`Environment: ${process.env.NODE_ENV}`); logger.info('SIGTERM received, shutting down gracefully');
server.close(() => {
logger.info('Server closed');
process.exit(0);
});
});
process.on('SIGINT', () => {
logger.info('SIGINT received, shutting down gracefully');
server.close(() => {
logger.info('Server closed');
process.exit(0);
});
});
const server = app.listen(PORT, () => {
logger.info('Server started', {
port: PORT,
environment: process.env.NODE_ENV,
nodeVersion: process.version
});
}); });
module.exports = app; module.exports = app;

View File

@ -0,0 +1,368 @@
const Queue = require('bull');
const logger = require('./logger');
const metrics = require('./metrics');
const emailService = require('../services/email');
// Configure Redis connection for Bull
const redisConfig = {
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379'),
password: process.env.REDIS_PASSWORD || undefined,
db: parseInt(process.env.REDIS_DB || '0'),
// Retry strategy for connection failures
retryStrategy: (times) => {
const delay = Math.min(times * 50, 2000);
return delay;
},
// Enable offline queue
enableOfflineQueue: true,
maxRetriesPerRequest: 3
};
// Create email queue
const emailQueue = new Queue('email-queue', {
redis: redisConfig,
defaultJobOptions: {
attempts: 3,
backoff: {
type: 'exponential',
delay: 2000 // Start with 2 seconds, then 4, 8, etc.
},
removeOnComplete: {
age: 24 * 3600, // Keep completed jobs for 24 hours
count: 1000 // Keep last 1000 completed jobs
},
removeOnFail: {
age: 7 * 24 * 3600 // Keep failed jobs for 7 days
},
timeout: 30000 // 30 seconds timeout per job
}
});
// Process email jobs
emailQueue.process(async (job) => {
const { type, data } = job.data;
const start = Date.now();
logger.info('Processing email job', {
jobId: job.id,
type,
attempt: job.attemptsMade + 1,
maxAttempts: job.opts.attempts
});
try {
let result;
switch (type) {
case 'campaign':
result = await emailService.sendCampaignEmail(data);
break;
case 'verification':
result = await emailService.sendVerificationEmail(data);
break;
case 'login-details':
result = await emailService.sendLoginDetails(data);
break;
case 'broadcast':
result = await emailService.sendBroadcast(data);
break;
case 'response-verification':
result = await emailService.sendResponseVerificationEmail(data);
break;
default:
throw new Error(`Unknown email type: ${type}`);
}
const duration = (Date.now() - start) / 1000;
// Record metrics
metrics.recordEmailSent(
data.campaignId || 'system',
data.representativeLevel || 'unknown'
);
metrics.observeEmailSendDuration(
data.campaignId || 'system',
duration
);
logger.logEmailSent(
data.to || data.email,
data.campaignId || type,
'success'
);
return result;
} catch (error) {
const duration = (Date.now() - start) / 1000;
// Record failure metrics
metrics.recordEmailFailed(
data.campaignId || 'system',
error.code || 'unknown'
);
logger.logEmailFailed(
data.to || data.email,
data.campaignId || type,
error
);
// Throw error to trigger retry
throw error;
}
});
// Queue event handlers
emailQueue.on('completed', (job, result) => {
logger.info('Email job completed', {
jobId: job.id,
type: job.data.type,
duration: Date.now() - job.timestamp
});
});
emailQueue.on('failed', (job, err) => {
logger.error('Email job failed', {
jobId: job.id,
type: job.data.type,
attempt: job.attemptsMade,
maxAttempts: job.opts.attempts,
error: err.message,
willRetry: job.attemptsMade < job.opts.attempts
});
});
emailQueue.on('stalled', (job) => {
logger.warn('Email job stalled', {
jobId: job.id,
type: job.data.type
});
});
emailQueue.on('error', (error) => {
logger.error('Email queue error', { error: error.message });
});
// Update queue size metric every 10 seconds
setInterval(async () => {
try {
const counts = await emailQueue.getJobCounts();
const queueSize = counts.waiting + counts.active;
metrics.setEmailQueueSize(queueSize);
} catch (error) {
logger.warn('Failed to update queue metrics', { error: error.message });
}
}, 10000);
/**
* Email Queue Service
* Provides methods to enqueue different types of emails
*/
class EmailQueueService {
/**
* Send campaign email (to representative)
*/
async sendCampaignEmail(emailData) {
const job = await emailQueue.add(
{
type: 'campaign',
data: emailData
},
{
priority: 2, // Normal priority
jobId: `campaign-${emailData.campaignId}-${Date.now()}`
}
);
logger.info('Campaign email queued', {
jobId: job.id,
campaignId: emailData.campaignId,
recipient: emailData.to
});
return { jobId: job.id, queued: true };
}
/**
* Send email verification
*/
async sendVerificationEmail(emailData) {
const job = await emailQueue.add(
{
type: 'verification',
data: emailData
},
{
priority: 1, // High priority - user waiting
jobId: `verification-${emailData.email}-${Date.now()}`
}
);
logger.info('Verification email queued', {
jobId: job.id,
email: emailData.email
});
return { jobId: job.id, queued: true };
}
/**
* Send login details
*/
async sendLoginDetails(emailData) {
const job = await emailQueue.add(
{
type: 'login-details',
data: emailData
},
{
priority: 1, // High priority
jobId: `login-${emailData.email}-${Date.now()}`
}
);
logger.info('Login details email queued', {
jobId: job.id,
email: emailData.email
});
return { jobId: job.id, queued: true };
}
/**
* Send broadcast to users
*/
async sendBroadcast(emailData) {
const job = await emailQueue.add(
{
type: 'broadcast',
data: emailData
},
{
priority: 3, // Lower priority - batch operation
jobId: `broadcast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
}
);
logger.info('Broadcast email queued', {
jobId: job.id,
recipientCount: emailData.recipients?.length || 1
});
return { jobId: job.id, queued: true };
}
/**
* Send response verification email
*/
async sendResponseVerificationEmail(emailData) {
const job = await emailQueue.add(
{
type: 'response-verification',
data: emailData
},
{
priority: 2,
jobId: `response-verification-${emailData.email}-${Date.now()}`
}
);
logger.info('Response verification email queued', {
jobId: job.id,
email: emailData.email
});
return { jobId: job.id, queued: true };
}
/**
* Get job status
*/
async getJobStatus(jobId) {
const job = await emailQueue.getJob(jobId);
if (!job) {
return { status: 'not_found' };
}
const state = await job.getState();
const progress = job.progress();
return {
jobId: job.id,
status: state,
progress,
attempts: job.attemptsMade,
data: job.data,
createdAt: job.timestamp,
processedAt: job.processedOn,
finishedAt: job.finishedOn,
failedReason: job.failedReason
};
}
/**
* Get queue statistics
*/
async getQueueStats() {
const counts = await emailQueue.getJobCounts();
const jobs = {
waiting: await emailQueue.getWaiting(0, 10),
active: await emailQueue.getActive(0, 10),
completed: await emailQueue.getCompleted(0, 10),
failed: await emailQueue.getFailed(0, 10)
};
return {
counts,
samples: {
waiting: jobs.waiting.map(j => ({ id: j.id, type: j.data.type })),
active: jobs.active.map(j => ({ id: j.id, type: j.data.type })),
completed: jobs.completed.slice(0, 5).map(j => ({ id: j.id, type: j.data.type })),
failed: jobs.failed.slice(0, 5).map(j => ({ id: j.id, type: j.data.type, reason: j.failedReason }))
}
};
}
/**
* Clean old jobs
*/
async cleanQueue(grace = 24 * 3600 * 1000) {
const cleaned = await emailQueue.clean(grace, 'completed');
logger.info('Queue cleaned', { removedJobs: cleaned.length });
return { cleaned: cleaned.length };
}
/**
* Pause queue
*/
async pauseQueue() {
await emailQueue.pause();
logger.warn('Email queue paused');
return { paused: true };
}
/**
* Resume queue
*/
async resumeQueue() {
await emailQueue.resume();
logger.info('Email queue resumed');
return { resumed: true };
}
/**
* Get queue instance (for advanced operations)
*/
getQueue() {
return emailQueue;
}
}
module.exports = new EmailQueueService();

View File

@ -0,0 +1,350 @@
const logger = require('./logger');
/**
* Analytics tracking utilities for the Influence application
* Provides helpers for tracking campaign conversion, representative response rates,
* user retention, and geographic participation
*/
class Analytics {
constructor() {
this.cache = {
campaignStats: new Map(),
userRetention: new Map(),
geographicData: new Map()
};
}
/**
* Track campaign conversion rate
* @param {string} campaignId - Campaign identifier
* @param {number} visitors - Number of unique visitors
* @param {number} participants - Number of participants who took action
*/
trackCampaignConversion(campaignId, visitors, participants) {
const conversionRate = visitors > 0 ? (participants / visitors) * 100 : 0;
const stats = {
campaignId,
visitors,
participants,
conversionRate: conversionRate.toFixed(2),
timestamp: new Date().toISOString()
};
this.cache.campaignStats.set(campaignId, stats);
logger.info('Campaign conversion tracked', {
event: 'analytics_campaign_conversion',
...stats
});
return stats;
}
/**
* Calculate representative response rate
* @param {string} representativeLevel - Level (Federal, Provincial, Municipal)
* @param {number} emailsSent - Total emails sent
* @param {number} responsesReceived - Total responses received
*/
calculateRepresentativeResponseRate(representativeLevel, emailsSent, responsesReceived) {
const responseRate = emailsSent > 0 ? (responsesReceived / emailsSent) * 100 : 0;
const stats = {
representativeLevel,
emailsSent,
responsesReceived,
responseRate: responseRate.toFixed(2),
timestamp: new Date().toISOString()
};
logger.info('Representative response rate calculated', {
event: 'analytics_response_rate',
...stats
});
return stats;
}
/**
* Track user retention
* @param {string} userId - User identifier
* @param {string} action - Action type (login, campaign_participation, etc.)
*/
trackUserRetention(userId, action) {
let userData = this.cache.userRetention.get(userId) || {
userId,
firstSeen: new Date().toISOString(),
lastSeen: new Date().toISOString(),
actionCount: 0,
actions: []
};
userData.lastSeen = new Date().toISOString();
userData.actionCount += 1;
userData.actions.push({
action,
timestamp: new Date().toISOString()
});
// Keep only last 100 actions per user
if (userData.actions.length > 100) {
userData.actions = userData.actions.slice(-100);
}
this.cache.userRetention.set(userId, userData);
// Determine if user is one-time or repeat
const daysBetween = this.getDaysBetween(userData.firstSeen, userData.lastSeen);
const isRepeatUser = daysBetween > 0 || userData.actionCount > 1;
logger.info('User retention tracked', {
event: 'analytics_user_retention',
userId,
action,
isRepeatUser,
totalActions: userData.actionCount
});
return { ...userData, isRepeatUser };
}
/**
* Track geographic participation by postal code
* @param {string} postalCode - Canadian postal code
* @param {string} campaignId - Campaign identifier
*/
trackGeographicParticipation(postalCode, campaignId) {
const postalPrefix = postalCode.substring(0, 3).toUpperCase();
let geoData = this.cache.geographicData.get(postalPrefix) || {
postalPrefix,
participationCount: 0,
campaigns: new Set()
};
geoData.participationCount += 1;
geoData.campaigns.add(campaignId);
this.cache.geographicData.set(postalPrefix, geoData);
logger.info('Geographic participation tracked', {
event: 'analytics_geographic_participation',
postalPrefix,
campaignId,
totalParticipation: geoData.participationCount
});
return {
postalPrefix,
participationCount: geoData.participationCount,
uniqueCampaigns: geoData.campaigns.size
};
}
/**
* Get geographic heatmap data
* @returns {Array} Array of postal code prefixes with participation counts
*/
getGeographicHeatmap() {
const heatmapData = [];
for (const [postalPrefix, data] of this.cache.geographicData.entries()) {
heatmapData.push({
postalPrefix,
participationCount: data.participationCount,
uniqueCampaigns: data.campaigns.size
});
}
return heatmapData.sort((a, b) => b.participationCount - a.participationCount);
}
/**
* Analyze peak usage times
* @param {Array} events - Array of event objects with timestamps
* @returns {Object} Peak usage analysis
*/
analyzePeakUsageTimes(events) {
const hourCounts = new Array(24).fill(0);
const dayOfWeekCounts = new Array(7).fill(0);
events.forEach(event => {
const date = new Date(event.timestamp);
const hour = date.getHours();
const dayOfWeek = date.getDay();
hourCounts[hour] += 1;
dayOfWeekCounts[dayOfWeek] += 1;
});
const peakHour = hourCounts.indexOf(Math.max(...hourCounts));
const peakDay = dayOfWeekCounts.indexOf(Math.max(...dayOfWeekCounts));
const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
const analysis = {
peakHour: `${peakHour}:00`,
peakDay: dayNames[peakDay],
hourlyDistribution: hourCounts,
dailyDistribution: dayOfWeekCounts,
totalEvents: events.length
};
logger.info('Peak usage times analyzed', {
event: 'analytics_peak_usage',
...analysis
});
return analysis;
}
/**
* Track device breakdown
* @param {string} userAgent - User agent string
*/
trackDeviceType(userAgent) {
const isMobile = /Mobile|Android|iPhone|iPad|iPod/i.test(userAgent);
const isTablet = /iPad|Android.*Tablet/i.test(userAgent);
const isDesktop = !isMobile && !isTablet;
const deviceType = isTablet ? 'tablet' : isMobile ? 'mobile' : 'desktop';
logger.info('Device type tracked', {
event: 'analytics_device_type',
deviceType,
userAgent: userAgent.substring(0, 100)
});
return deviceType;
}
/**
* Track email deliverability
* @param {string} status - Status (success, bounce, spam, failed)
* @param {string} campaignId - Campaign identifier
* @param {Object} details - Additional details
*/
trackEmailDeliverability(status, campaignId, details = {}) {
const deliverabilityData = {
status,
campaignId,
timestamp: new Date().toISOString(),
...details
};
logger.info('Email deliverability tracked', {
event: 'analytics_email_deliverability',
...deliverabilityData
});
return deliverabilityData;
}
/**
* Generate campaign analytics report
* @param {string} campaignId - Campaign identifier
* @param {Object} data - Campaign data including visitors, participants, emails sent, etc.
*/
generateCampaignReport(campaignId, data) {
const {
visitors = 0,
participants = 0,
emailsSent = 0,
emailsFailed = 0,
responsesReceived = 0,
shareCount = 0,
avgTimeOnPage = 0
} = data;
const conversionRate = visitors > 0 ? ((participants / visitors) * 100).toFixed(2) : 0;
const emailSuccessRate = emailsSent > 0 ? (((emailsSent - emailsFailed) / emailsSent) * 100).toFixed(2) : 0;
const responseRate = emailsSent > 0 ? ((responsesReceived / emailsSent) * 100).toFixed(2) : 0;
const shareRate = participants > 0 ? ((shareCount / participants) * 100).toFixed(2) : 0;
const report = {
campaignId,
metrics: {
visitors,
participants,
conversionRate: `${conversionRate}%`,
emailsSent,
emailsFailed,
emailSuccessRate: `${emailSuccessRate}%`,
responsesReceived,
responseRate: `${responseRate}%`,
shareCount,
shareRate: `${shareRate}%`,
avgTimeOnPage: `${avgTimeOnPage}s`
},
insights: {
performance: conversionRate > 5 ? 'excellent' : conversionRate > 2 ? 'good' : 'needs improvement',
emailHealth: emailSuccessRate > 95 ? 'excellent' : emailSuccessRate > 85 ? 'good' : 'poor',
engagement: responseRate > 10 ? 'high' : responseRate > 3 ? 'medium' : 'low'
},
generatedAt: new Date().toISOString()
};
logger.info('Campaign report generated', {
event: 'analytics_campaign_report',
campaignId,
...report.metrics
});
return report;
}
/**
* Get user retention summary
* @returns {Object} User retention statistics
*/
getUserRetentionSummary() {
let oneTimeUsers = 0;
let repeatUsers = 0;
let totalActions = 0;
for (const [userId, userData] of this.cache.userRetention.entries()) {
totalActions += userData.actionCount;
const daysBetween = this.getDaysBetween(userData.firstSeen, userData.lastSeen);
if (daysBetween > 0 || userData.actionCount > 1) {
repeatUsers += 1;
} else {
oneTimeUsers += 1;
}
}
const totalUsers = oneTimeUsers + repeatUsers;
const retentionRate = totalUsers > 0 ? ((repeatUsers / totalUsers) * 100).toFixed(2) : 0;
return {
totalUsers,
oneTimeUsers,
repeatUsers,
retentionRate: `${retentionRate}%`,
avgActionsPerUser: totalUsers > 0 ? (totalActions / totalUsers).toFixed(2) : 0
};
}
/**
* Helper: Calculate days between two dates
*/
getDaysBetween(date1, date2) {
const d1 = new Date(date1);
const d2 = new Date(date2);
const diffTime = Math.abs(d2 - d1);
return Math.floor(diffTime / (1000 * 60 * 60 * 24));
}
/**
* Clear cache (for testing or memory management)
*/
clearCache() {
this.cache.campaignStats.clear();
this.cache.userRetention.clear();
this.cache.geographicData.clear();
logger.info('Analytics cache cleared');
}
}
module.exports = new Analytics();

View File

@ -0,0 +1,380 @@
const axios = require('axios');
const os = require('os');
const fs = require('fs').promises;
const path = require('path');
const nodemailer = require('nodemailer');
const logger = require('./logger');
/**
* Health check utility for monitoring all system dependencies
*/
class HealthCheck {
constructor() {
this.services = {
nocodb: { name: 'NocoDB', healthy: false, lastCheck: null, details: {} },
smtp: { name: 'SMTP Server', healthy: false, lastCheck: null, details: {} },
representAPI: { name: 'Represent API', healthy: false, lastCheck: null, details: {} },
disk: { name: 'Disk Space', healthy: false, lastCheck: null, details: {} },
memory: { name: 'Memory', healthy: false, lastCheck: null, details: {} },
};
}
/**
* Check NocoDB connectivity and API health
*/
async checkNocoDB() {
const start = Date.now();
try {
const response = await axios.get(`${process.env.NOCODB_API_URL}/db/meta/projects`, {
headers: {
'xc-token': process.env.NOCODB_API_TOKEN
},
timeout: 5000
});
const duration = Date.now() - start;
this.services.nocodb = {
name: 'NocoDB',
healthy: response.status === 200,
lastCheck: new Date().toISOString(),
details: {
status: response.status,
responseTime: `${duration}ms`,
url: process.env.NOCODB_API_URL
}
};
logger.logHealthCheck('nocodb', 'healthy', { responseTime: duration });
return this.services.nocodb;
} catch (error) {
this.services.nocodb = {
name: 'NocoDB',
healthy: false,
lastCheck: new Date().toISOString(),
details: {
error: error.message,
url: process.env.NOCODB_API_URL
}
};
logger.logHealthCheck('nocodb', 'unhealthy', { error: error.message });
return this.services.nocodb;
}
}
/**
* Check SMTP server connectivity
*/
async checkSMTP() {
const start = Date.now();
// Skip SMTP check if in test mode
if (process.env.EMAIL_TEST_MODE === 'true') {
this.services.smtp = {
name: 'SMTP Server',
healthy: true,
lastCheck: new Date().toISOString(),
details: {
mode: 'test',
message: 'SMTP test mode enabled - emails logged only'
}
};
return this.services.smtp;
}
try {
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT || '587'),
secure: process.env.SMTP_SECURE === 'true',
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS
},
connectionTimeout: 5000
});
await transporter.verify();
const duration = Date.now() - start;
this.services.smtp = {
name: 'SMTP Server',
healthy: true,
lastCheck: new Date().toISOString(),
details: {
host: process.env.SMTP_HOST,
port: process.env.SMTP_PORT,
responseTime: `${duration}ms`
}
};
logger.logHealthCheck('smtp', 'healthy', { responseTime: duration });
return this.services.smtp;
} catch (error) {
this.services.smtp = {
name: 'SMTP Server',
healthy: false,
lastCheck: new Date().toISOString(),
details: {
error: error.message,
host: process.env.SMTP_HOST
}
};
logger.logHealthCheck('smtp', 'unhealthy', { error: error.message });
return this.services.smtp;
}
}
/**
* Check Represent API availability
*/
async checkRepresentAPI() {
const start = Date.now();
const testUrl = `${process.env.REPRESENT_API_BASE || 'https://represent.opennorth.ca'}/representatives/house-of-commons/`;
try {
const response = await axios.get(testUrl, {
timeout: 5000
});
const duration = Date.now() - start;
this.services.representAPI = {
name: 'Represent API',
healthy: response.status === 200,
lastCheck: new Date().toISOString(),
details: {
status: response.status,
responseTime: `${duration}ms`,
url: testUrl
}
};
logger.logHealthCheck('represent-api', 'healthy', { responseTime: duration });
return this.services.representAPI;
} catch (error) {
this.services.representAPI = {
name: 'Represent API',
healthy: false,
lastCheck: new Date().toISOString(),
details: {
error: error.message,
url: testUrl
}
};
logger.logHealthCheck('represent-api', 'unhealthy', { error: error.message });
return this.services.representAPI;
}
}
/**
* Check disk space for uploads directory
*/
async checkDiskSpace() {
try {
const uploadsDir = path.join(__dirname, '../public/uploads');
// Ensure directory exists
try {
await fs.access(uploadsDir);
} catch {
await fs.mkdir(uploadsDir, { recursive: true });
}
// Get directory size
const directorySize = await this.getDirectorySize(uploadsDir);
// Get system disk usage (platform-specific approximation)
const freeSpace = os.freemem();
const totalSpace = os.totalmem();
const usedPercentage = ((totalSpace - freeSpace) / totalSpace) * 100;
// Consider healthy if less than 90% full
const healthy = usedPercentage < 90;
this.services.disk = {
name: 'Disk Space',
healthy,
lastCheck: new Date().toISOString(),
details: {
uploadsSize: this.formatBytes(directorySize),
systemUsedPercentage: `${usedPercentage.toFixed(2)}%`,
freeSpace: this.formatBytes(freeSpace),
totalSpace: this.formatBytes(totalSpace),
path: uploadsDir
}
};
logger.logHealthCheck('disk', healthy ? 'healthy' : 'unhealthy', this.services.disk.details);
return this.services.disk;
} catch (error) {
this.services.disk = {
name: 'Disk Space',
healthy: false,
lastCheck: new Date().toISOString(),
details: {
error: error.message
}
};
logger.logHealthCheck('disk', 'unhealthy', { error: error.message });
return this.services.disk;
}
}
/**
* Check memory usage
*/
async checkMemory() {
try {
const totalMemory = os.totalmem();
const freeMemory = os.freemem();
const usedMemory = totalMemory - freeMemory;
const usedPercentage = (usedMemory / totalMemory) * 100;
// Get Node.js process memory
const processMemory = process.memoryUsage();
// Consider healthy if less than 85% used
const healthy = usedPercentage < 85;
this.services.memory = {
name: 'Memory',
healthy,
lastCheck: new Date().toISOString(),
details: {
system: {
total: this.formatBytes(totalMemory),
used: this.formatBytes(usedMemory),
free: this.formatBytes(freeMemory),
usedPercentage: `${usedPercentage.toFixed(2)}%`
},
process: {
rss: this.formatBytes(processMemory.rss),
heapTotal: this.formatBytes(processMemory.heapTotal),
heapUsed: this.formatBytes(processMemory.heapUsed),
external: this.formatBytes(processMemory.external)
}
}
};
logger.logHealthCheck('memory', healthy ? 'healthy' : 'unhealthy', this.services.memory.details);
return this.services.memory;
} catch (error) {
this.services.memory = {
name: 'Memory',
healthy: false,
lastCheck: new Date().toISOString(),
details: {
error: error.message
}
};
logger.logHealthCheck('memory', 'unhealthy', { error: error.message });
return this.services.memory;
}
}
/**
* Run all health checks
*/
async checkAll() {
const results = await Promise.allSettled([
this.checkNocoDB(),
this.checkSMTP(),
this.checkRepresentAPI(),
this.checkDiskSpace(),
this.checkMemory()
]);
const overall = {
status: Object.values(this.services).every(s => s.healthy) ? 'healthy' : 'degraded',
timestamp: new Date().toISOString(),
services: this.services,
uptime: process.uptime(),
version: process.env.npm_package_version || '1.0.0',
environment: process.env.NODE_ENV || 'development'
};
// Check if any critical services are down
// Note: In development, NocoDB might not be available yet
// Only mark as unhealthy if multiple critical services fail
const criticalServices = ['nocodb'];
const criticalDown = criticalServices.filter(name => !this.services[name].healthy);
// Allow degraded state if only external services (NocoDB, Represent API) are down
// Only fail health check if core services (disk, memory) are unhealthy
const coreServicesHealthy = this.services.disk.healthy && this.services.memory.healthy;
if (!coreServicesHealthy) {
overall.status = 'unhealthy';
} else if (criticalDown.length > 0) {
// Mark as degraded (not unhealthy) if external services are down
// This allows the container to start even if NocoDB isn't ready
overall.status = 'degraded';
}
return overall;
}
/**
* Get health check for a specific service
*/
async checkService(serviceName) {
const checkMethods = {
nocodb: () => this.checkNocoDB(),
smtp: () => this.checkSMTP(),
representAPI: () => this.checkRepresentAPI(),
disk: () => this.checkDiskSpace(),
memory: () => this.checkMemory()
};
if (checkMethods[serviceName]) {
return await checkMethods[serviceName]();
}
throw new Error(`Unknown service: ${serviceName}`);
}
/**
* Helper: Get directory size recursively
*/
async getDirectorySize(directory) {
let size = 0;
try {
const files = await fs.readdir(directory);
for (const file of files) {
const filePath = path.join(directory, file);
const stats = await fs.stat(filePath);
if (stats.isDirectory()) {
size += await this.getDirectorySize(filePath);
} else {
size += stats.size;
}
}
} catch (error) {
logger.warn(`Error calculating directory size: ${error.message}`);
}
return size;
}
/**
* Helper: Format bytes to human readable
*/
formatBytes(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
}
module.exports = new HealthCheck();

View File

@ -0,0 +1,190 @@
const winston = require('winston');
const DailyRotateFile = require('winston-daily-rotate-file');
const path = require('path');
// Define log levels
const levels = {
error: 0,
warn: 1,
info: 2,
http: 3,
debug: 4,
};
// Define colors for each level
const colors = {
error: 'red',
warn: 'yellow',
info: 'green',
http: 'magenta',
debug: 'white',
};
// Tell winston about our colors
winston.addColors(colors);
// Define which level to log based on environment
const level = () => {
const env = process.env.NODE_ENV || 'development';
const isDevelopment = env === 'development';
return isDevelopment ? 'debug' : 'info';
};
// Define log format
const logFormat = winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss:ms' }),
winston.format.errors({ stack: true }),
winston.format.splat(),
winston.format.json()
);
// Console format for development (pretty print)
const consoleFormat = winston.format.combine(
winston.format.colorize({ all: true }),
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.printf(
(info) => `${info.timestamp} ${info.level}: ${info.message}${info.stack ? '\n' + info.stack : ''}`
)
);
// Define transports
const transports = [
// Console output
new winston.transports.Console({
format: consoleFormat,
handleExceptions: true,
}),
// Error logs - rotate daily
new DailyRotateFile({
filename: path.join(__dirname, '../../logs/error-%DATE%.log'),
datePattern: 'YYYY-MM-DD',
level: 'error',
format: logFormat,
maxSize: '20m',
maxFiles: '14d',
handleExceptions: true,
}),
// Combined logs - rotate daily
new DailyRotateFile({
filename: path.join(__dirname, '../../logs/combined-%DATE%.log'),
datePattern: 'YYYY-MM-DD',
format: logFormat,
maxSize: '20m',
maxFiles: '14d',
}),
// HTTP logs - rotate daily
new DailyRotateFile({
filename: path.join(__dirname, '../../logs/http-%DATE%.log'),
datePattern: 'YYYY-MM-DD',
level: 'http',
format: logFormat,
maxSize: '20m',
maxFiles: '7d',
}),
];
// Create the logger
const logger = winston.createLogger({
level: level(),
levels,
format: logFormat,
transports,
exitOnError: false,
});
// Create a stream object for Morgan HTTP logger
logger.stream = {
write: (message) => {
logger.http(message.trim());
},
};
// Helper methods for common logging patterns
logger.logRequest = (req, res, duration) => {
const logData = {
method: req.method,
url: req.url,
status: res.statusCode,
duration: `${duration}ms`,
ip: req.ip,
userAgent: req.get('user-agent'),
};
if (res.statusCode >= 500) {
logger.error('Request failed', logData);
} else if (res.statusCode >= 400) {
logger.warn('Request error', logData);
} else {
logger.http('Request completed', logData);
}
};
logger.logEmailSent = (recipient, campaign, status) => {
logger.info('Email sent', {
event: 'email_sent',
recipient,
campaign,
status,
timestamp: new Date().toISOString(),
});
};
logger.logEmailFailed = (recipient, campaign, error) => {
logger.error('Email failed', {
event: 'email_failed',
recipient,
campaign,
error: error.message,
stack: error.stack,
timestamp: new Date().toISOString(),
});
};
logger.logAuth = (action, username, success, ip) => {
const level = success ? 'info' : 'warn';
logger[level]('Auth action', {
event: 'auth',
action,
username,
success,
ip,
timestamp: new Date().toISOString(),
});
};
logger.logCampaignAction = (action, campaignId, userId, ip) => {
logger.info('Campaign action', {
event: 'campaign_action',
action,
campaignId,
userId,
ip,
timestamp: new Date().toISOString(),
});
};
logger.logRateLimitHit = (endpoint, ip, limit) => {
logger.warn('Rate limit hit', {
event: 'rate_limit',
endpoint,
ip,
limit,
timestamp: new Date().toISOString(),
});
};
logger.logHealthCheck = (service, status, details = {}) => {
const level = status === 'healthy' ? 'info' : 'error';
logger[level]('Health check', {
event: 'health_check',
service,
status,
...details,
timestamp: new Date().toISOString(),
});
};
module.exports = logger;

View File

@ -0,0 +1,276 @@
const client = require('prom-client');
// Create a Registry to register the metrics
const register = new client.Registry();
// Add default metrics (CPU, memory, etc.)
client.collectDefaultMetrics({
register,
prefix: 'influence_app_'
});
// Custom metrics for the Influence application
// Email metrics
const emailsSentTotal = new client.Counter({
name: 'influence_emails_sent_total',
help: 'Total number of emails sent successfully',
labelNames: ['campaign_id', 'representative_level'],
registers: [register]
});
const emailsFailedTotal = new client.Counter({
name: 'influence_emails_failed_total',
help: 'Total number of emails that failed to send',
labelNames: ['campaign_id', 'error_type'],
registers: [register]
});
const emailQueueSize = new client.Gauge({
name: 'influence_email_queue_size',
help: 'Current number of emails in the queue',
registers: [register]
});
const emailSendDuration = new client.Histogram({
name: 'influence_email_send_duration_seconds',
help: 'Time taken to send an email',
labelNames: ['campaign_id'],
buckets: [0.1, 0.5, 1, 2, 5, 10],
registers: [register]
});
// API metrics
const httpRequestDuration = new client.Histogram({
name: 'influence_http_request_duration_seconds',
help: 'Duration of HTTP requests in seconds',
labelNames: ['method', 'route', 'status_code'],
buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5],
registers: [register]
});
const httpRequestsTotal = new client.Counter({
name: 'influence_http_requests_total',
help: 'Total number of HTTP requests',
labelNames: ['method', 'route', 'status_code'],
registers: [register]
});
// User metrics
const activeUsersGauge = new client.Gauge({
name: 'influence_active_users_total',
help: 'Number of currently active users',
registers: [register]
});
const userRegistrationsTotal = new client.Counter({
name: 'influence_user_registrations_total',
help: 'Total number of user registrations',
registers: [register]
});
const loginAttemptsTotal = new client.Counter({
name: 'influence_login_attempts_total',
help: 'Total number of login attempts',
labelNames: ['status'],
registers: [register]
});
// Campaign metrics
const campaignCreationsTotal = new client.Counter({
name: 'influence_campaign_creations_total',
help: 'Total number of campaigns created',
registers: [register]
});
const campaignParticipationTotal = new client.Counter({
name: 'influence_campaign_participation_total',
help: 'Total number of campaign participations',
labelNames: ['campaign_id'],
registers: [register]
});
const activeCampaignsGauge = new client.Gauge({
name: 'influence_active_campaigns_total',
help: 'Number of currently active campaigns',
registers: [register]
});
const campaignConversionRate = new client.Gauge({
name: 'influence_campaign_conversion_rate',
help: 'Campaign conversion rate (participants / visitors)',
labelNames: ['campaign_id'],
registers: [register]
});
// Representative metrics
const representativeLookupTotal = new client.Counter({
name: 'influence_representative_lookup_total',
help: 'Total number of representative lookups',
labelNames: ['lookup_type'],
registers: [register]
});
const representativeResponsesTotal = new client.Counter({
name: 'influence_representative_responses_total',
help: 'Total number of representative responses received',
labelNames: ['representative_level'],
registers: [register]
});
const representativeResponseRate = new client.Gauge({
name: 'influence_representative_response_rate',
help: 'Representative response rate (responses / emails sent)',
labelNames: ['representative_level'],
registers: [register]
});
// Rate limiting metrics
const rateLimitHitsTotal = new client.Counter({
name: 'influence_rate_limit_hits_total',
help: 'Total number of rate limit hits',
labelNames: ['endpoint', 'limit_type'],
registers: [register]
});
// External service metrics
const externalServiceRequestsTotal = new client.Counter({
name: 'influence_external_service_requests_total',
help: 'Total number of requests to external services',
labelNames: ['service', 'status'],
registers: [register]
});
const externalServiceLatency = new client.Histogram({
name: 'influence_external_service_latency_seconds',
help: 'Latency of external service requests',
labelNames: ['service'],
buckets: [0.1, 0.5, 1, 2, 5, 10],
registers: [register]
});
// Geographic metrics
const participationByPostalCode = new client.Counter({
name: 'influence_participation_by_postal_code_total',
help: 'Participation count by postal code prefix',
labelNames: ['postal_prefix'],
registers: [register]
});
// Middleware to track HTTP requests
const metricsMiddleware = (req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = (Date.now() - start) / 1000;
const route = req.route ? req.route.path : req.path;
const labels = {
method: req.method,
route: route,
status_code: res.statusCode
};
httpRequestDuration.observe(labels, duration);
httpRequestsTotal.inc(labels);
});
next();
};
// Helper functions for common metric operations
const metrics = {
// Email metrics
recordEmailSent: (campaignId, representativeLevel) => {
emailsSentTotal.inc({ campaign_id: campaignId, representative_level: representativeLevel });
},
recordEmailFailed: (campaignId, errorType) => {
emailsFailedTotal.inc({ campaign_id: campaignId, error_type: errorType });
},
setEmailQueueSize: (size) => {
emailQueueSize.set(size);
},
observeEmailSendDuration: (campaignId, durationSeconds) => {
emailSendDuration.observe({ campaign_id: campaignId }, durationSeconds);
},
// User metrics
setActiveUsers: (count) => {
activeUsersGauge.set(count);
},
recordUserRegistration: () => {
userRegistrationsTotal.inc();
},
recordLoginAttempt: (success) => {
loginAttemptsTotal.inc({ status: success ? 'success' : 'failed' });
},
// Campaign metrics
recordCampaignCreation: () => {
campaignCreationsTotal.inc();
},
recordCampaignParticipation: (campaignId) => {
campaignParticipationTotal.inc({ campaign_id: campaignId });
},
setActiveCampaigns: (count) => {
activeCampaignsGauge.set(count);
},
setCampaignConversionRate: (campaignId, rate) => {
campaignConversionRate.set({ campaign_id: campaignId }, rate);
},
// Representative metrics
recordRepresentativeLookup: (lookupType) => {
representativeLookupTotal.inc({ lookup_type: lookupType });
},
recordRepresentativeResponse: (representativeLevel) => {
representativeResponsesTotal.inc({ representative_level: representativeLevel });
},
setRepresentativeResponseRate: (representativeLevel, rate) => {
representativeResponseRate.set({ representative_level: representativeLevel }, rate);
},
// Rate limiting metrics
recordRateLimitHit: (endpoint, limitType) => {
rateLimitHitsTotal.inc({ endpoint, limit_type: limitType });
},
// External service metrics
recordExternalServiceRequest: (service, success) => {
externalServiceRequestsTotal.inc({ service, status: success ? 'success' : 'failed' });
},
observeExternalServiceLatency: (service, durationSeconds) => {
externalServiceLatency.observe({ service }, durationSeconds);
},
// Geographic metrics
recordParticipationByPostalCode: (postalCode) => {
const prefix = postalCode.substring(0, 3).toUpperCase();
participationByPostalCode.inc({ postal_prefix: prefix });
},
// Get metrics endpoint handler
getMetrics: async () => {
return await register.metrics();
},
// Get content type for metrics
getContentType: () => {
return register.contentType;
},
// Middleware
middleware: metricsMiddleware
};
module.exports = metrics;

View File

@ -16,24 +16,127 @@ function cleanupExpiredEntries() {
// Clean up expired entries every minute // Clean up expired entries every minute
setInterval(cleanupExpiredEntries, 60 * 1000); setInterval(cleanupExpiredEntries, 60 * 1000);
// General API rate limiter // Custom key generator that's safer with trust proxy
const general = rateLimit({ const safeKeyGenerator = (req) => {
windowMs: 15 * 60 * 1000, // 15 minutes return req.ip || req.connection?.remoteAddress || 'unknown';
max: 100, // limit each IP to 100 requests per windowMs };
message: {
error: 'Too many requests from this IP, please try again later.', // Production-grade rate limiting configuration
retryAfter: 15 * 60 // 15 minutes in seconds const rateLimitConfig = {
// Email sending - very restrictive (5 emails per hour per IP)
emailSend: {
windowMs: 60 * 60 * 1000, // 1 hour
max: 5,
message: {
error: 'Email rate limit exceeded',
message: 'Too many emails sent from this IP. Maximum 5 emails per hour allowed.',
retryAfter: 3600
}
}, },
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers // Representative lookup - moderate (30 lookups per minute)
// Use a custom key generator that's safer with trust proxy representativeLookup: {
keyGenerator: (req) => { windowMs: 60 * 1000, // 1 minute
// Fallback to connection remote address if req.ip is not available max: 30,
return req.ip || req.connection?.remoteAddress || 'unknown'; message: {
error: 'Representative lookup rate limit exceeded',
message: 'Too many representative lookups. Maximum 30 per minute allowed.',
retryAfter: 60
}
}, },
// Login attempts - strict (5 attempts per 15 minutes)
login: {
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5,
message: {
error: 'Login rate limit exceeded',
message: 'Too many login attempts. Please try again in 15 minutes.',
retryAfter: 900
}
},
// Public API - standard (100 requests per minute)
publicAPI: {
windowMs: 60 * 1000, // 1 minute
max: 100,
message: {
error: 'API rate limit exceeded',
message: 'Too many requests from this IP. Please try again later.',
retryAfter: 60
}
},
// Campaign creation/editing - moderate (10 per hour)
campaignMutation: {
windowMs: 60 * 60 * 1000, // 1 hour
max: 10,
message: {
error: 'Campaign mutation rate limit exceeded',
message: 'Too many campaign operations. Maximum 10 per hour allowed.',
retryAfter: 3600
}
},
// General fallback - legacy compatibility
general: {
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100,
message: {
error: 'Too many requests from this IP, please try again later.',
retryAfter: 900
}
}
};
// Create rate limiter instances
const emailSend = rateLimit({
...rateLimitConfig.emailSend,
standardHeaders: true,
legacyHeaders: false,
skipSuccessfulRequests: false,
keyGenerator: safeKeyGenerator
}); });
// Email sending rate limiter (general - keeps existing behavior) const representativeLookup = rateLimit({
...rateLimitConfig.representativeLookup,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: safeKeyGenerator
});
const login = rateLimit({
...rateLimitConfig.login,
standardHeaders: true,
legacyHeaders: false,
skipSuccessfulRequests: false, // Count all attempts
keyGenerator: safeKeyGenerator
});
const publicAPI = rateLimit({
...rateLimitConfig.publicAPI,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: safeKeyGenerator
});
const campaignMutation = rateLimit({
...rateLimitConfig.campaignMutation,
standardHeaders: true,
legacyHeaders: false,
skipSuccessfulRequests: false,
keyGenerator: safeKeyGenerator
});
// General API rate limiter (legacy - kept for backward compatibility)
const general = rateLimit({
...rateLimitConfig.general,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: safeKeyGenerator
});
// Email sending rate limiter (legacy - kept for backward compatibility)
const email = rateLimit({ const email = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour windowMs: 60 * 60 * 1000, // 1 hour
max: 10, // limit each IP to 10 emails per hour max: 10, // limit each IP to 10 emails per hour
@ -43,12 +146,8 @@ const email = rateLimit({
}, },
standardHeaders: true, standardHeaders: true,
legacyHeaders: false, legacyHeaders: false,
skipSuccessfulRequests: false, // Don't skip counting successful requests skipSuccessfulRequests: false,
// Use a custom key generator that's safer with trust proxy keyGenerator: safeKeyGenerator
keyGenerator: (req) => {
// Fallback to connection remote address if req.ip is not available
return req.ip || req.connection?.remoteAddress || 'unknown';
},
}); });
// Custom middleware for per-recipient email rate limiting // Custom middleware for per-recipient email rate limiting
@ -90,16 +189,23 @@ const representAPI = rateLimit({
}, },
standardHeaders: true, standardHeaders: true,
legacyHeaders: false, legacyHeaders: false,
// Use a custom key generator that's safer with trust proxy keyGenerator: safeKeyGenerator
keyGenerator: (req) => {
// Fallback to connection remote address if req.ip is not available
return req.ip || req.connection?.remoteAddress || 'unknown';
},
}); });
module.exports = { module.exports = {
// Legacy exports (backward compatibility)
general, general,
email, email,
representAPI,
// New granular rate limiters
emailSend,
representativeLookup,
login,
publicAPI,
campaignMutation,
perRecipientEmailLimiter, perRecipientEmailLimiter,
representAPI
// Export config for testing/monitoring
rateLimitConfig
}; };

View File

@ -1,7 +1,31 @@
// Validate Canadian postal code format // Validate Canadian postal code format
// Full Canadian postal code validation with proper FSA/LDU rules
function validatePostalCode(postalCode) { function validatePostalCode(postalCode) {
const regex = /^[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d$/; // Remove whitespace and convert to uppercase
return regex.test(postalCode); const cleaned = postalCode.replace(/\s/g, '').toUpperCase();
// Must be exactly 6 characters
if (cleaned.length !== 6) return false;
// Pattern: A1A 1A1 where A is letter and 1 is digit
const pattern = /^[A-Z]\d[A-Z]\d[A-Z]\d$/;
if (!pattern.test(cleaned)) return false;
// First character cannot be D, F, I, O, Q, U, W, or Z
const invalidFirstChars = ['D', 'F', 'I', 'O', 'Q', 'U', 'W', 'Z'];
if (invalidFirstChars.includes(cleaned[0])) return false;
// Second position (LDU) cannot be 0
if (cleaned[1] === '0') return false;
// Third character cannot be D, F, I, O, Q, or U
const invalidThirdChars = ['D', 'F', 'I', 'O', 'Q', 'U'];
if (invalidThirdChars.includes(cleaned[2])) return false;
// Fifth character cannot be D, F, I, O, Q, or U
if (invalidThirdChars.includes(cleaned[4])) return false;
return true;
} }
// Validate Alberta postal code (starts with T) // Validate Alberta postal code (starts with T)
@ -10,10 +34,29 @@ function validateAlbertaPostalCode(postalCode) {
return formatted.startsWith('T') && validatePostalCode(postalCode); return formatted.startsWith('T') && validatePostalCode(postalCode);
} }
// Validate email format // Validate email format with stricter rules
function validateEmail(email) { function validateEmail(email) {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; // RFC 5322 simplified email validation
return regex.test(email); const regex = /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
if (!regex.test(email)) return false;
// Additional checks
const parts = email.split('@');
if (parts.length !== 2) return false;
const [localPart, domain] = parts;
// Local part max 64 characters
if (localPart.length > 64) return false;
// Domain must have at least one dot and valid TLD
if (!domain.includes('.')) return false;
// Check for consecutive dots
if (email.includes('..')) return false;
return true;
} }
// Format postal code to standard format (A1A 1A1) // Format postal code to standard format (A1A 1A1)
@ -25,16 +68,57 @@ function formatPostalCode(postalCode) {
return cleaned; return cleaned;
} }
// Sanitize string input to prevent XSS // Sanitize string input to prevent XSS and injection attacks
function sanitizeString(str) { function sanitizeString(str) {
if (typeof str !== 'string') return str; if (typeof str !== 'string') return str;
return str return str
.replace(/[<>]/g, '') // Remove angle brackets .replace(/[<>]/g, '') // Remove angle brackets
.replace(/javascript:/gi, '') // Remove javascript: protocol
.replace(/on\w+\s*=/gi, '') // Remove event handlers
.replace(/eval\s*\(/gi, '') // Remove eval calls
.trim() .trim()
.substring(0, 1000); // Limit length .substring(0, 1000); // Limit length
} }
// Sanitize HTML content for email templates
function sanitizeHtmlContent(html) {
if (typeof html !== 'string') return html;
// Remove dangerous tags and attributes
let sanitized = html
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
.replace(/<iframe\b[^<]*(?:(?!<\/iframe>)<[^<]*)*<\/iframe>/gi, '')
.replace(/<object\b[^<]*(?:(?!<\/object>)<[^<]*)*<\/object>/gi, '')
.replace(/<embed[^>]*>/gi, '')
.replace(/javascript:/gi, '')
.replace(/on\w+\s*=/gi, '');
return sanitized;
}
// Validate SQL/NoSQL injection attempts in where clauses
function validateWhereClause(whereClause) {
if (typeof whereClause !== 'string') return false;
// Check for SQL injection patterns
const suspiciousPatterns = [
/;\s*drop\s+/i,
/;\s*delete\s+/i,
/;\s*update\s+/i,
/;\s*insert\s+/i,
/union\s+select/i,
/exec\s*\(/i,
/execute\s*\(/i,
/--/,
/\/\*/,
/xp_/i,
/sp_/i
];
return !suspiciousPatterns.some(pattern => pattern.test(whereClause));
}
// Validate required fields in request body // Validate required fields in request body
function validateRequiredFields(body, requiredFields) { function validateRequiredFields(body, requiredFields) {
const errors = []; const errors = [];
@ -130,6 +214,8 @@ module.exports = {
validateEmail, validateEmail,
formatPostalCode, formatPostalCode,
sanitizeString, sanitizeString,
sanitizeHtmlContent,
validateWhereClause,
validateRequiredFields, validateRequiredFields,
containsSuspiciousContent, containsSuspiciousContent,
generateSlug, generateSlug,

View File

@ -1,25 +1,53 @@
version: '3.8'
services: services:
app: app:
build: build:
context: ./app context: ./app
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: influence-app
ports: ports:
- "3333:3333" - "3333:3333"
env_file: env_file:
- .env - .env
environment:
- NODE_ENV=production
- REDIS_HOST=redis-changemaker
- REDIS_PORT=6379
volumes: volumes:
- ./app:/usr/src/app - ./app:/usr/src/app
- /usr/src/app/node_modules - /usr/src/app/node_modules
restart: unless-stopped - uploads-data:/usr/src/app/public/uploads
- logs-data:/usr/src/app/logs
restart: always
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3333/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
deploy:
resources:
limits:
cpus: '2'
memory: 2G
reservations:
cpus: '0.5'
memory: 512M
networks:
- changemakerlite_changemaker-lite
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# MailHog for local email testing and development volumes:
mailhog: uploads-data:
image: mailhog/mailhog:latest driver: local
ports: logs-data:
- "1025:1025" # SMTP server driver: local
- "8025:8025" # Web UI
restart: unless-stopped
networks: networks:
changemaker-lite: changemakerlite_changemaker-lite:
external: true external: true

View File

@ -9,14 +9,14 @@ NOCODB_API_TOKEN=your_nocodb_api_token_here
NOCODB_PROJECT_ID=your_project_id NOCODB_PROJECT_ID=your_project_id
# SMTP Configuration # SMTP Configuration
# Configure your email service provider settings # Configure your email service provider settings. See below for development mode smtp
SMTP_HOST=smtp.your-provider.com # SMTP_HOST=smtp.your-provider.com
SMTP_PORT=587 # SMTP_PORT=587
SMTP_SECURE=false # SMTP_SECURE=false
SMTP_USER=your-email@domain.com # SMTP_USER=your-email@domain.com
SMTP_PASS=your_email_password_or_app_password # SMTP_PASS=your_email_password_or_app_password
SMTP_FROM_EMAIL=your-sender@domain.com # SMTP_FROM_EMAIL=your-sender@domain.com
SMTP_FROM_NAME="Your Campaign Name" # SMTP_FROM_NAME="Your Campaign Name"
# Admin Configuration # Admin Configuration
# Set a strong password for admin access # Set a strong password for admin access
@ -60,22 +60,47 @@ NOCODB_TABLE_REPRESENTATIVE_RESPONSES=
NOCODB_TABLE_RESPONSE_UPVOTES= NOCODB_TABLE_RESPONSE_UPVOTES=
NOCODB_TABLE_EMAIL_VERIFICATIONS= NOCODB_TABLE_EMAIL_VERIFICATIONS=
# Redis Configuration (for email queue and caching)
# Uses centralized Redis from root docker-compose.yml
REDIS_HOST=redis-changemaker
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
# Backup Configuration
BACKUP_RETENTION_DAYS=30
BACKUP_ENCRYPTION_KEY=generate_a_strong_encryption_key_here
BACKUP_BASE_DIR=/path/to/backups
USE_S3_BACKUP=false
S3_BACKUP_BUCKET=
S3_BACKUP_PREFIX=influence-backups
REMOVE_LOCAL_AFTER_S3=false
# Monitoring Configuration (optional)
GRAFANA_ADMIN_PASSWORD=change_this_for_production
# Optional: Development Mode Settings # Optional: Development Mode Settings
# Uncomment and modify these for local development with MailHog # Uncomment and modify these for local development with centralized MailHog
# SMTP_HOST=mailhog # MailHog runs from root docker-compose.yml as a shared service
# SMTP_PORT=1025 SMTP_HOST=mailhog-changemaker
# SMTP_SECURE=false SMTP_PORT=1025
# SMTP_USER= SMTP_SECURE=false
# SMTP_PASS= SMTP_USER=
# SMTP_FROM_EMAIL=dev@albertainfluence.local SMTP_PASS=
# SMTP_FROM_NAME="BNKops Influence Campaign (DEV)" SMTP_FROM_EMAIL=dev@albertainfluence.local
SMTP_FROM_NAME="BNKops Influence Campaign (DEV)"
# Security Notes: # Security Notes:
# - Keep your .env file secure and never commit it to version control # - Keep your .env file secure and never commit it to version control
# - Use strong, unique passwords for ADMIN_PASSWORD # - Use strong, unique passwords for ADMIN_PASSWORD
# - Generate a secure random string for SESSION_SECRET # - Generate a secure random string for SESSION_SECRET (64+ characters)
# - For production, ensure EMAIL_TEST_MODE=false # - For production, ensure EMAIL_TEST_MODE=false and HTTPS=true
# - Use app passwords or API keys for SMTP_PASS, not your main email password # - Use app passwords or API keys for SMTP_PASS, not your main email password
# - Rotate all secrets regularly (every 90 days recommended)
# Generate Secure Secrets:
# SESSION_SECRET: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
# BACKUP_ENCRYPTION_KEY: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
# Common SMTP Provider Examples: # Common SMTP Provider Examples:
# #
@ -105,4 +130,11 @@ NOCODB_TABLE_EMAIL_VERIFICATIONS=
# SMTP_PORT=587 # SMTP_PORT=587
# SMTP_SECURE=false # SMTP_SECURE=false
# SMTP_USER=apikey # SMTP_USER=apikey
# SMTP_PASS=your_sendgrid_api_key # SMTP_PASS=your_sendgrid_api_key
#
# AWS SES:
# SMTP_HOST=email-smtp.us-east-1.amazonaws.com
# SMTP_PORT=587
# SMTP_SECURE=false
# SMTP_USER=your_aws_smtp_username
# SMTP_PASS=your_aws_smtp_password

306
influence/scripts/backup.sh Executable file
View File

@ -0,0 +1,306 @@
#!/bin/bash
##############################################################################
# Influence App Backup Script
# Automated backup for NocoDB data, uploaded files, and configurations
##############################################################################
set -e # Exit on error
# Configuration
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
APP_DIR="$(dirname "$SCRIPT_DIR")"
BACKUP_DIR="${BACKUP_BASE_DIR:-$APP_DIR/backups}"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_NAME="influence_backup_${TIMESTAMP}"
BACKUP_PATH="$BACKUP_DIR/$BACKUP_NAME"
# Retention settings
RETENTION_DAYS=${BACKUP_RETENTION_DAYS:-30}
# S3/External storage settings (optional)
USE_S3=${USE_S3_BACKUP:-false}
S3_BUCKET=${S3_BACKUP_BUCKET:-""}
S3_PREFIX=${S3_BACKUP_PREFIX:-"influence-backups"}
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# Logging functions
log_info() {
echo -e "${GREEN}[INFO]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1"
}
log_warn() {
echo -e "${YELLOW}[WARN]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1"
}
# Check if required commands exist
check_dependencies() {
local missing_deps=()
for cmd in tar gzip du; do
if ! command -v $cmd &> /dev/null; then
missing_deps+=($cmd)
fi
done
if [ "$USE_S3" = "true" ]; then
if ! command -v aws &> /dev/null; then
missing_deps+=(aws-cli)
fi
fi
if [ ${#missing_deps[@]} -ne 0 ]; then
log_error "Missing dependencies: ${missing_deps[*]}"
exit 1
fi
}
# Create backup directory
create_backup_dir() {
log_info "Creating backup directory: $BACKUP_PATH"
mkdir -p "$BACKUP_PATH"
}
# Backup uploaded files
backup_uploads() {
log_info "Backing up uploaded files..."
local uploads_dir="$APP_DIR/public/uploads"
if [ -d "$uploads_dir" ]; then
local size=$(du -sh "$uploads_dir" | cut -f1)
log_info "Uploads directory size: $size"
tar -czf "$BACKUP_PATH/uploads.tar.gz" -C "$APP_DIR/public" uploads
log_info "Uploads backed up successfully"
else
log_warn "Uploads directory not found, skipping"
fi
}
# Backup environment configuration (encrypted)
backup_env_config() {
log_info "Backing up environment configuration..."
local env_file="$APP_DIR/.env"
if [ -f "$env_file" ]; then
# Copy .env file (will be encrypted later)
cp "$env_file" "$BACKUP_PATH/.env"
# Encrypt if encryption key is provided
if [ -n "${BACKUP_ENCRYPTION_KEY}" ]; then
log_info "Encrypting environment file..."
openssl enc -aes-256-cbc -salt -pbkdf2 \
-in "$BACKUP_PATH/.env" \
-out "$BACKUP_PATH/.env.encrypted" \
-k "${BACKUP_ENCRYPTION_KEY}"
rm "$BACKUP_PATH/.env" # Remove unencrypted version
log_info "Environment file encrypted"
else
log_warn "BACKUP_ENCRYPTION_KEY not set, .env file not encrypted"
fi
else
log_warn ".env file not found, skipping"
fi
}
# Backup NocoDB data (if accessible)
backup_nocodb() {
log_info "Checking NocoDB backup capability..."
# Load environment variables
if [ -f "$APP_DIR/.env" ]; then
source "$APP_DIR/.env"
fi
# If NocoDB is accessible via API, export data
if [ -n "$NOCODB_API_URL" ] && [ -n "$NOCODB_API_TOKEN" ]; then
log_info "Exporting NocoDB metadata..."
# Export project metadata
curl -s -H "xc-token: $NOCODB_API_TOKEN" \
"$NOCODB_API_URL/api/v1/db/meta/projects" \
-o "$BACKUP_PATH/nocodb_projects.json" || log_warn "Failed to export NocoDB projects"
log_info "NocoDB metadata exported"
else
log_warn "NocoDB credentials not available, skipping data export"
fi
}
# Backup logs
backup_logs() {
log_info "Backing up log files..."
local logs_dir="$APP_DIR/logs"
if [ -d "$logs_dir" ]; then
# Only backup logs from last 7 days
find "$logs_dir" -name "*.log" -mtime -7 -print0 | \
tar -czf "$BACKUP_PATH/logs.tar.gz" --null -T -
log_info "Logs backed up successfully"
else
log_warn "Logs directory not found, skipping"
fi
}
# Create backup manifest
create_manifest() {
log_info "Creating backup manifest..."
cat > "$BACKUP_PATH/manifest.txt" << EOF
Influence App Backup
====================
Backup Date: $(date)
Backup Name: $BACKUP_NAME
Server: $(hostname)
Contents:
$(ls -lh "$BACKUP_PATH")
Total Size: $(du -sh "$BACKUP_PATH" | cut -f1)
EOF
log_info "Manifest created"
}
# Compress entire backup
compress_backup() {
log_info "Compressing backup..."
cd "$BACKUP_DIR"
tar -czf "${BACKUP_NAME}.tar.gz" "$BACKUP_NAME"
# Remove uncompressed directory
rm -rf "$BACKUP_NAME"
local size=$(du -sh "${BACKUP_NAME}.tar.gz" | cut -f1)
log_info "Backup compressed: ${BACKUP_NAME}.tar.gz ($size)"
}
# Upload to S3 (if enabled)
upload_to_s3() {
if [ "$USE_S3" != "true" ]; then
return 0
fi
if [ -z "$S3_BUCKET" ]; then
log_error "S3_BUCKET not set, cannot upload to S3"
return 1
fi
log_info "Uploading backup to S3..."
aws s3 cp "${BACKUP_DIR}/${BACKUP_NAME}.tar.gz" \
"s3://${S3_BUCKET}/${S3_PREFIX}/${BACKUP_NAME}.tar.gz" \
--storage-class STANDARD_IA
if [ $? -eq 0 ]; then
log_info "Backup uploaded to S3 successfully"
# Optionally remove local backup after successful S3 upload
if [ "${REMOVE_LOCAL_AFTER_S3:-false}" = "true" ]; then
log_info "Removing local backup after S3 upload"
rm "${BACKUP_DIR}/${BACKUP_NAME}.tar.gz"
fi
else
log_error "Failed to upload backup to S3"
return 1
fi
}
# Clean old backups
cleanup_old_backups() {
log_info "Cleaning up backups older than $RETENTION_DAYS days..."
# Clean local backups
find "$BACKUP_DIR" -name "influence_backup_*.tar.gz" -mtime +$RETENTION_DAYS -delete
# Clean S3 backups (if enabled)
if [ "$USE_S3" = "true" ] && [ -n "$S3_BUCKET" ]; then
log_info "Cleaning old S3 backups..."
# This requires AWS CLI with proper permissions
aws s3 ls "s3://${S3_BUCKET}/${S3_PREFIX}/" | \
while read -r line; do
createDate=$(echo $line | awk '{print $1" "$2}')
createDateSec=$(date -d "$createDate" +%s)
olderThan=$(date -d "-${RETENTION_DAYS} days" +%s)
if [ $createDateSec -lt $olderThan ]; then
fileName=$(echo $line | awk '{print $4}')
if [ -n "$fileName" ]; then
aws s3 rm "s3://${S3_BUCKET}/${S3_PREFIX}/${fileName}"
log_info "Deleted old S3 backup: $fileName"
fi
fi
done
fi
log_info "Cleanup completed"
}
# Verify backup integrity
verify_backup() {
log_info "Verifying backup integrity..."
if tar -tzf "${BACKUP_DIR}/${BACKUP_NAME}.tar.gz" > /dev/null 2>&1; then
log_info "Backup integrity verified successfully"
return 0
else
log_error "Backup integrity check failed!"
return 1
fi
}
# Main backup process
main() {
log_info "Starting Influence App backup..."
log_info "Backup path: $BACKUP_PATH"
# Check dependencies
check_dependencies
# Create backup directory
create_backup_dir
# Perform backups
backup_uploads
backup_env_config
backup_nocodb
backup_logs
# Create manifest
create_manifest
# Compress backup
compress_backup
# Verify backup
if ! verify_backup; then
log_error "Backup verification failed, aborting"
exit 1
fi
# Upload to S3
upload_to_s3 || log_warn "S3 upload failed or skipped"
# Cleanup old backups
cleanup_old_backups
log_info "Backup completed successfully: ${BACKUP_NAME}.tar.gz"
log_info "Backup size: $(du -sh "${BACKUP_DIR}/${BACKUP_NAME}.tar.gz" | cut -f1)"
}
# Run main function
main "$@"

204
influence/scripts/toggle-smtp.sh Executable file
View File

@ -0,0 +1,204 @@
#!/bin/bash
# Toggle SMTP Configuration Script
# Switches between development (MailHog) and production (ProtonMail) SMTP settings
set -e
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
ENV_FILE="$SCRIPT_DIR/../.env"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
echo -e "${BLUE}"
cat << "EOF"
╔══════════════════════════════════════════════════╗
║ SMTP Configuration Toggle Tool ║
║ Influence Campaign Application ║
╚══════════════════════════════════════════════════╝
EOF
echo -e "${NC}"
# Check if .env file exists
if [ ! -f "$ENV_FILE" ]; then
echo -e "${RED}Error: .env file not found at $ENV_FILE${NC}"
exit 1
fi
# Detect current mode
if grep -q "^SMTP_HOST=mailhog" "$ENV_FILE"; then
CURRENT_MODE="development"
CURRENT_HOST="mailhog"
elif grep -q "^SMTP_HOST=smtp.protonmail.ch" "$ENV_FILE"; then
CURRENT_MODE="production"
CURRENT_HOST="smtp.protonmail.ch"
else
CURRENT_MODE="unknown"
CURRENT_HOST=$(grep "^SMTP_HOST=" "$ENV_FILE" | cut -d'=' -f2)
fi
echo -e "${YELLOW}Current Configuration:${NC}"
echo -e " Mode: ${BLUE}$CURRENT_MODE${NC}"
echo -e " SMTP Host: ${BLUE}$CURRENT_HOST${NC}"
echo -e " Email Test Mode: ${BLUE}$(grep "^EMAIL_TEST_MODE=" "$ENV_FILE" | cut -d'=' -f2)${NC}"
echo -e " Node Environment: ${BLUE}$(grep "^NODE_ENV=" "$ENV_FILE" | cut -d'=' -f2)${NC}"
echo ""
# Ask user what mode they want
echo "Select SMTP configuration mode:"
echo " 1) Development (MailHog - emails captured locally)"
echo " 2) Production (ProtonMail - emails sent to real recipients)"
echo " 3) Cancel"
echo ""
read -p "Enter your choice (1-3): " choice
case $choice in
1)
TARGET_MODE="development"
;;
2)
TARGET_MODE="production"
;;
3)
echo -e "${YELLOW}Cancelled. No changes made.${NC}"
exit 0
;;
*)
echo -e "${RED}Invalid choice. Exiting.${NC}"
exit 1
;;
esac
# Confirm if already in target mode
if [ "$CURRENT_MODE" = "$TARGET_MODE" ]; then
echo -e "${GREEN}Already in $TARGET_MODE mode. No changes needed.${NC}"
exit 0
fi
# Create backup
BACKUP_FILE="${ENV_FILE}.backup_$(date +%Y%m%d_%H%M%S)"
cp "$ENV_FILE" "$BACKUP_FILE"
echo -e "${GREEN}✓ Backup created: $BACKUP_FILE${NC}"
# Apply configuration based on target mode
if [ "$TARGET_MODE" = "development" ]; then
echo ""
echo -e "${BLUE}Switching to DEVELOPMENT mode...${NC}"
echo ""
# Comment out production SMTP settings
sed -i 's/^SMTP_HOST=smtp.protonmail.ch/# SMTP_HOST=smtp.protonmail.ch/' "$ENV_FILE"
sed -i 's/^SMTP_PORT=587$/# SMTP_PORT=587/' "$ENV_FILE"
sed -i 's/^SMTP_SECURE=false$/# SMTP_SECURE=false/' "$ENV_FILE"
sed -i 's/^SMTP_USER=cmlite@bnkops.ca/# SMTP_USER=cmlite@bnkops.ca/' "$ENV_FILE"
sed -i 's/^SMTP_PASS=QMLDV1E2MWDHNJMY/# SMTP_PASS=QMLDV1E2MWDHNJMY/' "$ENV_FILE"
sed -i 's/^SMTP_FROM_EMAIL=cmlite@bnkops.ca/# SMTP_FROM_EMAIL=cmlite@bnkops.ca/' "$ENV_FILE"
sed -i 's/^SMTP_FROM_NAME="BNKops Influence Campaign"$/# SMTP_FROM_NAME="BNKops Influence Campaign"/' "$ENV_FILE"
# Uncomment development SMTP settings
sed -i 's/^# SMTP_HOST=mailhog-changemaker$/SMTP_HOST=mailhog-changemaker/' "$ENV_FILE"
sed -i 's/^# SMTP_PORT=1025$/SMTP_PORT=1025/' "$ENV_FILE"
sed -i 's/^# SMTP_SECURE=false$/SMTP_SECURE=false/' "$ENV_FILE"
sed -i 's/^# SMTP_USER=test$/SMTP_USER=test/' "$ENV_FILE"
sed -i 's/^# SMTP_PASS=test$/SMTP_PASS=test/' "$ENV_FILE"
sed -i 's/^# SMTP_FROM_EMAIL=dev@albertainfluence.local$/SMTP_FROM_EMAIL=dev@albertainfluence.local/' "$ENV_FILE"
sed -i 's/^# SMTP_FROM_NAME="BNKops Influence Campaign (DEV)"$/SMTP_FROM_NAME="BNKops Influence Campaign (DEV)"/' "$ENV_FILE"
# Update other settings
sed -i 's/^NODE_ENV=production$/NODE_ENV=development/' "$ENV_FILE"
sed -i 's/^EMAIL_TEST_MODE=false$/EMAIL_TEST_MODE=true/' "$ENV_FILE"
sed -i 's|^APP_URL=https://influence.cmlite.org$|APP_URL=http://localhost:3333|' "$ENV_FILE"
echo -e "${GREEN}✓ Configured for DEVELOPMENT mode${NC}"
echo ""
echo -e "${YELLOW}Development Configuration:${NC}"
echo " • SMTP: MailHog (mailhog-changemaker:1025)"
echo " • Web UI: http://localhost:8025"
echo " • Email Test Mode: Enabled"
echo " • Node Environment: development"
echo " • App URL: http://localhost:3333"
echo ""
echo -e "${YELLOW}Next Steps:${NC}"
echo " 1. Ensure MailHog is running (from root directory):"
echo -e " ${BLUE}cd .. && docker compose up -d mailhog${NC}"
echo ""
echo " 2. Start the Influence app:"
echo -e " ${BLUE}docker compose up -d${NC}"
echo ""
echo " 3. Access MailHog UI to view captured emails:"
echo -e " ${BLUE}http://localhost:8025${NC}"
elif [ "$TARGET_MODE" = "production" ]; then
echo ""
echo -e "${BLUE}Switching to PRODUCTION mode...${NC}"
echo ""
# Confirm production switch
echo -e "${RED}⚠️ WARNING: Production mode will send REAL emails!${NC}"
read -p "Are you sure you want to switch to production? (yes/no): " confirm
if [ "$confirm" != "yes" ]; then
echo -e "${YELLOW}Cancelled. Restoring backup...${NC}"
mv "$BACKUP_FILE" "$ENV_FILE"
echo -e "${GREEN}✓ Original configuration restored${NC}"
exit 0
fi
# Comment out development SMTP settings
sed -i 's/^SMTP_HOST=mailhog-changemaker$/# SMTP_HOST=mailhog-changemaker/' "$ENV_FILE"
sed -i 's/^SMTP_PORT=1025$/# SMTP_PORT=1025/' "$ENV_FILE"
sed -i 's/^SMTP_SECURE=false$/# SMTP_SECURE=false/' "$ENV_FILE"
sed -i 's/^SMTP_USER=test$/# SMTP_USER=test/' "$ENV_FILE"
sed -i 's/^SMTP_PASS=test$/# SMTP_PASS=test/' "$ENV_FILE"
sed -i 's/^SMTP_FROM_EMAIL=dev@albertainfluence.local$/# SMTP_FROM_EMAIL=dev@albertainfluence.local/' "$ENV_FILE"
sed -i 's/^SMTP_FROM_NAME="BNKops Influence Campaign (DEV)"$/# SMTP_FROM_NAME="BNKops Influence Campaign (DEV)"/' "$ENV_FILE"
# Uncomment production SMTP settings
sed -i 's/^# SMTP_HOST=smtp.protonmail.ch$/SMTP_HOST=smtp.protonmail.ch/' "$ENV_FILE"
sed -i 's/^# SMTP_PORT=587$/SMTP_PORT=587/' "$ENV_FILE"
sed -i 's/^# SMTP_SECURE=false$/SMTP_SECURE=false/' "$ENV_FILE"
sed -i 's/^# SMTP_USER=cmlite@bnkops.ca$/SMTP_USER=cmlite@bnkops.ca/' "$ENV_FILE"
sed -i 's/^# SMTP_PASS=QMLDV1E2MWDHNJMY$/SMTP_PASS=QMLDV1E2MWDHNJMY/' "$ENV_FILE"
sed -i 's/^# SMTP_FROM_EMAIL=cmlite@bnkops.ca$/SMTP_FROM_EMAIL=cmlite@bnkops.ca/' "$ENV_FILE"
sed -i 's/^# SMTP_FROM_NAME="BNKops Influence Campaign"$/SMTP_FROM_NAME="BNKops Influence Campaign"/' "$ENV_FILE"
# Update other settings
sed -i 's/^NODE_ENV=development$/NODE_ENV=production/' "$ENV_FILE"
sed -i 's/^EMAIL_TEST_MODE=true$/EMAIL_TEST_MODE=false/' "$ENV_FILE"
sed -i 's|^APP_URL=http://localhost:3333$|APP_URL=https://influence.cmlite.org|' "$ENV_FILE"
echo -e "${GREEN}✓ Configured for PRODUCTION mode${NC}"
echo ""
echo -e "${YELLOW}Production Configuration:${NC}"
echo " • SMTP: ProtonMail (smtp.protonmail.ch)"
echo " • Email Test Mode: Disabled"
echo " • Node Environment: production"
echo " • App URL: https://influence.cmlite.org"
echo ""
echo -e "${RED}⚠️ IMPORTANT: Emails will be sent to REAL recipients!${NC}"
echo ""
echo -e "${YELLOW}Next Steps:${NC}"
echo " 1. Restart the application:"
echo -e " ${BLUE}docker compose down && docker compose up -d${NC}"
echo ""
echo " 2. Verify SMTP connection:"
echo -e " ${BLUE}docker compose logs -f${NC}"
fi
echo ""
echo -e "${GREEN}════════════════════════════════════════════════${NC}"
echo -e "${GREEN}✓ Configuration updated successfully!${NC}"
echo -e "${GREEN}════════════════════════════════════════════════${NC}"
echo ""
echo "Current settings:"
grep "^SMTP_HOST=" "$ENV_FILE"
grep "^NODE_ENV=" "$ENV_FILE"
grep "^EMAIL_TEST_MODE=" "$ENV_FILE"
grep "^APP_URL=" "$ENV_FILE"
echo ""
echo -e "Backup saved at: ${BLUE}$BACKUP_FILE${NC}"

View File

@ -35,30 +35,6 @@ cp example.env .env
Edit the `.env` file with your configuration: Edit the `.env` file with your configuration:
```env
# Server Configuration
NODE_ENV=production
PORT=3333
# NocoDB Configuration
NOCODB_API_URL=https://your-nocodb-instance.com
NOCODB_API_TOKEN=your_nocodb_api_token_here
NOCODB_PROJECT_ID=your_project_id_here
# Email Configuration (Production SMTP)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=your_email@gmail.com
SMTP_PASS=your_app_password
SMTP_FROM_NAME=BNKops Influence Campaign
SMTP_FROM_EMAIL=your_email@gmail.com
# Rate Limiting
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100
```
#### Development Mode Configuration #### Development Mode Configuration
For development and testing, use MailHog to catch emails: For development and testing, use MailHog to catch emails: