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:
parent
4b5e2249dd
commit
e5c32ad25a
49
config.sh
49
config.sh
@ -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 ""
|
||||||
|
|||||||
12
configs/grafana/dashboards.yml
Normal file
12
configs/grafana/dashboards.yml
Normal 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
|
||||||
11
configs/grafana/datasources.yml
Normal file
11
configs/grafana/datasources.yml
Normal 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
|
||||||
93
configs/prometheus/alerts.yml
Normal file
93
configs/prometheus/alerts.yml
Normal 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 }}."
|
||||||
54
configs/prometheus/prometheus.yml
Normal file
54
configs/prometheus/prometheus.yml
Normal 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']
|
||||||
@ -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:
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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.)
|
|
||||||
@ -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 ./
|
||||||
|
|
||||||
|
|||||||
104
influence/app/middleware/csrf.js
Normal file
104
influence/app/middleware/csrf.js
Normal 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
|
||||||
|
};
|
||||||
@ -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",
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
368
influence/app/services/emailQueue.js
Normal file
368
influence/app/services/emailQueue.js
Normal 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();
|
||||||
350
influence/app/utils/analytics.js
Normal file
350
influence/app/utils/analytics.js
Normal 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();
|
||||||
380
influence/app/utils/health-check.js
Normal file
380
influence/app/utils/health-check.js
Normal 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();
|
||||||
190
influence/app/utils/logger.js
Normal file
190
influence/app/utils/logger.js
Normal 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;
|
||||||
276
influence/app/utils/metrics.js
Normal file
276
influence/app/utils/metrics.js
Normal 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;
|
||||||
@ -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
|
||||||
};
|
};
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
306
influence/scripts/backup.sh
Executable 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
204
influence/scripts/toggle-smtp.sh
Executable 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}"
|
||||||
24
mkdocs/docs/build/influence.md
vendored
24
mkdocs/docs/build/influence.md
vendored
@ -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:
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user