Compare commits

..

10 Commits

Author SHA1 Message Date
a04b7d5b43 First commit - Base changemaker-lite build with updated documentation. 2026-01-14 10:09:12 -07:00
84d1285677 Bunch of monitoring services added. Need to work through all the configuration next. 2025-12-14 11:31:22 -07:00
4ef4ac414b feat(campaigns): add highlighted campaign feature with admin controls and UI updates 2025-11-07 10:15:41 -07:00
1bdc2b9ae0 some updates 2025-11-06 23:26:42 -07:00
4d8b9effd0 feat(blog): add detailed update on Influence and Map app developments since August
A bunch of udpates to the listmonk sync to add influence to it
2025-10-25 12:45:35 -06:00
e5c32ad25a 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.
2025-10-23 11:33:00 -06:00
4b5e2249dd add verify button to the response wall and qr code generation 2025-10-17 11:30:26 -06:00
8372b8a4bd Visual updates to the response wall 2025-10-17 10:48:44 -06:00
91a3f62b93 Verfied response system for electeds 2025-10-16 12:12:54 -06:00
ffb09a01f8 geocoding fixes 2025-10-16 10:44:49 -06:00
940 changed files with 117769 additions and 88091 deletions

10
.gitignore vendored
View File

@ -4,6 +4,16 @@
/configs/code-server/.config/*
!/configs/code-server/.config/.gitkeep
# MkDocs cache and built site (created by containers)
/mkdocs/.cache/*
!/mkdocs/.cache/.gitkeep
/mkdocs/site/*
!/mkdocs/site/.gitkeep
# Homepage logs (created by container)
/configs/homepage/logs/*
!/configs/homepage/logs/.gitkeep
.env
.env*

View File

@ -107,3 +107,34 @@ This project is licensed under the Apache License 2.0 - https://opensource.org/l
This project used AI tools to assist in its creation and large amounts of the boilerplate code was reviewed using AI. AI tools (although not activated or connected) are pre-installed in the Coder docker image. See `docker.code-server` for more details.
While these tools can help generate code and documentation, they may also introduce errors or inaccuracies. Users should review and test all content to ensure it meets their requirements and standards.
## Troubleshooting
### Permission Denied Errors (EACCES)
If you see errors like `EACCES: permission denied` when starting containers, run the included fix script:
```bash
./fix-permissions.sh
```
This fixes permissions on directories that containers need to write to, such as:
- `configs/code-server/.config` and `.local` (Code Server)
- `mkdocs/.cache` (MkDocs social cards plugin)
- `mkdocs/site` (MkDocs built output)
If the script can't fix some directories (owned by a different container UID), it will prompt to use `sudo`.
### Manual Permission Fix
If you prefer to fix manually:
```bash
# Fix all permissions at once
sudo chown -R $(id -u):$(id -g) .
chmod -R 755 .
# Or fix specific directories
chmod -R 777 configs/code-server/.config configs/code-server/.local
chmod -R 777 mkdocs/.cache mkdocs/site
```

0
assets/images/.gitkeep Executable file
View File

0
assets/uploads/.gitkeep Executable file
View File

610
config.sh
View File

@ -82,6 +82,85 @@ backup_env_file() {
fi
}
# Function to fix permissions on directories that containers need to write to
# This prevents EACCES errors when containers try to create files/directories
fix_container_permissions() {
echo ""
echo "=== Fixing Container Directory Permissions ==="
echo "Setting up directories that containers need write access to..."
# Get the user/group IDs (default to 1000 if not set)
local user_id=${USER_ID:-1000}
local group_id=${GROUP_ID:-1000}
# Define directories that need to be writable by containers
# Format: "path:description"
local -a writable_dirs=(
"$SCRIPT_DIR/configs/code-server/.config:Code Server config"
"$SCRIPT_DIR/configs/code-server/.local:Code Server local data"
"$SCRIPT_DIR/mkdocs/.cache:MkDocs cache (social cards, etc.)"
"$SCRIPT_DIR/mkdocs/site:MkDocs built site"
"$SCRIPT_DIR/assets/uploads:Listmonk uploads"
"$SCRIPT_DIR/assets/images:Shared images"
)
local errors=0
for dir_entry in "${writable_dirs[@]}"; do
local dir_path="${dir_entry%%:*}"
local dir_desc="${dir_entry#*:}"
# Create directory if it doesn't exist
if [ ! -d "$dir_path" ]; then
echo " Creating: $dir_path ($dir_desc)"
mkdir -p "$dir_path"
fi
# Add .gitkeep to track empty directories in git
if [ ! -f "$dir_path/.gitkeep" ]; then
touch "$dir_path/.gitkeep"
fi
# Fix permissions - make writable by container user
if chmod -R 777 "$dir_path" 2>/dev/null; then
echo "$dir_desc: permissions fixed"
else
echo " ⚠️ $dir_desc: could not fix permissions (may need sudo)"
((errors++))
fi
done
# Handle directories that may have been created by containers with different UIDs
# These need special handling (may require sudo)
local -a potentially_owned_by_container=(
"$SCRIPT_DIR/mkdocs/site"
)
for dir_path in "${potentially_owned_by_container[@]}"; do
if [ -d "$dir_path" ]; then
# Check if we own the directory
if [ ! -w "$dir_path" ]; then
echo ""
echo " ⚠️ Directory $dir_path is not writable."
echo " This may have been created by a container with a different UID."
echo " To fix, run: sudo chown -R $user_id:$group_id $dir_path"
((errors++))
fi
fi
done
echo ""
if [ $errors -eq 0 ]; then
echo "✅ All container directories are properly configured!"
else
echo "⚠️ Some directories may need manual permission fixes."
echo " You can try: sudo chown -R $user_id:$group_id $SCRIPT_DIR"
fi
echo ""
return $errors
}
# Function to get all used ports on the system
get_used_ports() {
local used_ports=()
@ -185,6 +264,16 @@ initialize_available_ports() {
["MAP_PORT"]=3000
["INFLUENCE_PORT"]=3333
["MINI_QR_PORT"]=8089
["REDIS_PORT"]=6379
["PROMETHEUS_PORT"]=9090
["GRAFANA_PORT"]=3001
["CADVISOR_PORT"]=8080
["NODE_EXPORTER_PORT"]=9100
["REDIS_EXPORTER_PORT"]=9121
["ALERTMANAGER_PORT"]=9093
["GOTIFY_PORT"]=8889
["MAILHOG_SMTP_PORT"]=1025
["MAILHOG_WEB_PORT"]=8025
)
# Find available ports for each service
@ -252,6 +341,24 @@ MAP_PORT=${MAP_PORT:-3000}
INFLUENCE_PORT=${INFLUENCE_PORT:-3333}
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}
# Monitoring Exporters Ports
CADVISOR_PORT=${CADVISOR_PORT:-8080}
NODE_EXPORTER_PORT=${NODE_EXPORTER_PORT:-9100}
REDIS_EXPORTER_PORT=${REDIS_EXPORTER_PORT:-9121}
# Alerting Services Ports
ALERTMANAGER_PORT=${ALERTMANAGER_PORT:-9093}
GOTIFY_PORT=${GOTIFY_PORT:-8889}
# MailHog Email Testing Ports
MAILHOG_SMTP_PORT=${MAILHOG_SMTP_PORT:-1025}
MAILHOG_WEB_PORT=${MAILHOG_WEB_PORT:-8025}
# Domain Configuration
BASE_DOMAIN=https://changeme.org
DOMAIN=changeme.org
@ -301,6 +408,39 @@ NOCODB_DB_PASSWORD=changeMe
# Gitea Database Configuration
GITEA_DB_PASSWD=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
GRAFANA_ROOT_URL=http://localhost:3001
# Monitoring Exporters
CADVISOR_PORT=8080
NODE_EXPORTER_PORT=9100
REDIS_EXPORTER_PORT=9121
# Alertmanager (alert routing)
ALERTMANAGER_PORT=9093
# Gotify (push notifications)
GOTIFY_PORT=8889
GOTIFY_ADMIN_USER=admin
GOTIFY_ADMIN_PASSWORD=changeMe
GOTIFY_APP_TOKEN=
# Alert Email Configuration
ALERT_EMAIL_TO=admin@changeme.org
EOL
echo "New .env file created with conflict-free port assignments."
@ -321,6 +461,22 @@ EOL
echo "Map: ${MAP_PORT:-3000}"
echo "Influence: ${INFLUENCE_PORT:-3333}"
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 "=== Monitoring Services ==="
echo "cAdvisor: ${CADVISOR_PORT:-8080}"
echo "Node Exporter: ${NODE_EXPORTER_PORT:-9100}"
echo "Redis Exporter: ${REDIS_EXPORTER_PORT:-9121}"
echo "Alertmanager: ${ALERTMANAGER_PORT:-9093}"
echo "Gotify: ${GOTIFY_PORT:-8889}"
echo ""
echo "=== Email Testing ==="
echo "MailHog SMTP: ${MAILHOG_SMTP_PORT:-1025}"
echo "MailHog Web: ${MAILHOG_WEB_PORT:-8025}"
echo "================================"
}
@ -356,6 +512,7 @@ update_mkdocs_yml() {
# Function to update service URLs in services.yaml
update_services_yaml() {
local new_domain=$1
local local_ip=${2:-"localhost"} # Optional parameter for local IP address
if [ ! -f "$SERVICES_YAML" ]; then
echo "Warning: services.yaml not found at $SERVICES_YAML"
@ -370,32 +527,67 @@ update_services_yaml() {
cp "$SERVICES_YAML" "$backup_file"
echo "Created backup of services.yaml at $backup_file"
# Define service name to subdomain mapping
# Load environment variables to get port numbers
load_env_vars
# Define service name to subdomain mapping for Production tab
# This approach is URL-agnostic - it doesn't matter what the current URLs are
declare -A service_mappings=(
declare -A production_mappings=(
["Code Server"]="code.$new_domain"
["Listmonk"]="listmonk.$new_domain"
["NocoDB"]="db.$new_domain"
["Map Server"]="map.$new_domain"
["Influence"]="influence.$new_domain"
["Main Site"]="$new_domain"
["MkDocs (Live)"]="docs.$new_domain"
["Mini QR"]="qr.$new_domain"
["MailHog"]="mail.$new_domain"
["n8n"]="n8n.$new_domain"
["Gitea"]="git.$new_domain"
["Prometheus"]="prometheus.$new_domain"
["Grafana"]="grafana.$new_domain"
["Alertmanager"]="alertmanager.$new_domain"
["Gotify"]="gotify.$new_domain"
["cAdvisor"]="cadvisor.$new_domain"
)
# Process each service mapping
for service_name in "${!service_mappings[@]}"; do
local target_url="https://${service_mappings[$service_name]}"
# Define service name to local URL mapping for Local tab
declare -A local_mappings=(
["Code Server"]="$local_ip:${CODE_SERVER_PORT:-8888}"
["NocoDB"]="$local_ip:${NOCODB_PORT:-8090}"
["Homepage"]="$local_ip:${HOMEPAGE_PORT:-3010}"
["Main Site"]="$local_ip:${MKDOCS_SITE_SERVER_PORT:-4001}"
["MkDocs (Live)"]="$local_ip:${MKDOCS_PORT:-4000}"
["Mini QR"]="$local_ip:${MINI_QR_PORT:-8089}"
["Listmonk"]="$local_ip:${LISTMONK_PORT:-9001}"
["MailHog"]="$local_ip:${MAILHOG_WEB_PORT:-8025}"
["n8n"]="$local_ip:${N8N_PORT:-5678}"
["Gitea"]="$local_ip:${GITEA_WEB_PORT:-3030}"
["Prometheus"]="$local_ip:${PROMETHEUS_PORT:-9090}"
["Grafana"]="$local_ip:${GRAFANA_PORT:-3001}"
["Alertmanager"]="$local_ip:${ALERTMANAGER_PORT:-9093}"
["Gotify"]="$local_ip:${GOTIFY_PORT:-8889}"
["cAdvisor"]="$local_ip:${CADVISOR_PORT:-8080}"
["Node Exporter"]="$local_ip:${NODE_EXPORTER_PORT:-9100}/metrics"
["Redis Exporter"]="$local_ip:${REDIS_EXPORTER_PORT:-9121}/metrics"
)
# Use awk to find and update the href for each specific service
# This finds the service by name and updates its href regardless of current URL
# Update Production URLs
echo "Updating Production tab URLs..."
for service_name in "${!production_mappings[@]}"; do
local target_url="https://${production_mappings[$service_name]}"
# Use awk to find and update the href for Production services only
awk -v service="$service_name" -v new_url="$target_url" '
BEGIN { in_service = 0 }
BEGIN {
in_production = 0
in_service = 0
}
# Check if we found the service name
/- [^:]+:/ {
# Track if we are in Production section
/^- Production/ { in_production = 1; in_service = 0 }
/^- Local/ { in_production = 0; in_service = 0 }
# Check if we found the service name in Production section
in_production && /^ - [^:]+:/ {
if ($0 ~ (" - " service ":")) {
in_service = 1
} else {
@ -404,7 +596,7 @@ update_services_yaml() {
}
# If we are in the target service and find href line, update it
in_service && /href:/ {
in_production && in_service && /href:/ {
gsub(/href: "[^"]*"/, "href: \"" new_url "\"")
}
@ -412,13 +604,51 @@ update_services_yaml() {
{ print }
' "$SERVICES_YAML" > "${SERVICES_YAML}.tmp"
# Replace the original file with the updated version
mv "${SERVICES_YAML}.tmp" "$SERVICES_YAML"
echo " ✓ Updated $service_name -> $target_url"
echo " ✓ Production: $service_name -> $target_url"
done
echo "✅ All service URLs updated to use domain: $new_domain"
# Update Local URLs
echo "Updating Local tab URLs..."
for service_name in "${!local_mappings[@]}"; do
local target_url="http://${local_mappings[$service_name]}"
# Use awk to find and update the href for Local services only
awk -v service="$service_name" -v new_url="$target_url" '
BEGIN {
in_local = 0
in_service = 0
}
# Track if we are in Local section
/^- Local/ { in_local = 1; in_service = 0 }
/^- Production/ { in_local = 0; in_service = 0 }
# Check if we found the service name in Local section
in_local && /^ - [^:]+:/ {
if ($0 ~ (" - " service ":")) {
in_service = 1
} else {
in_service = 0
}
}
# If we are in the target service and find href line, update it
in_local && in_service && /href:/ {
gsub(/href: "[^"]*"/, "href: \"" new_url "\"")
}
# Print the line (modified or not)
{ print }
' "$SERVICES_YAML" > "${SERVICES_YAML}.tmp"
mv "${SERVICES_YAML}.tmp" "$SERVICES_YAML"
echo " ✓ Local: $service_name -> $target_url"
done
echo "✅ All service URLs updated:"
echo " - Production tab: $new_domain"
echo " - Local tab: $local_ip"
return 0
}
@ -537,6 +767,21 @@ ingress:
- hostname: qr.$new_domain
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}
- hostname: alertmanager.$new_domain
service: http://localhost:${ALERTMANAGER_PORT:-9093}
- hostname: gotify.$new_domain
service: http://localhost:${GOTIFY_PORT:-8889}
- hostname: cadvisor.$new_domain
service: http://localhost:${CADVISOR_PORT:-8080}
# Catch-all rule (required)
- service: http_status:404
EOL
@ -557,99 +802,6 @@ load_env_vars() {
fi
}
# Function to update or create the map's .env file with domain settings
update_map_env() {
local new_domain=$1
# Check if the map directory exists
if [ ! -d "$SCRIPT_DIR/map" ]; then
echo "Map directory not found at $SCRIPT_DIR/map"
return
fi
echo "Creating/updating map .env file at: $MAP_ENV_FILE"
cat > "$MAP_ENV_FILE" << EOL
NOCODB_API_URL=https://db.$new_domain/api/v1
NOCODB_API_TOKEN=changeme
# NocoDB View URL is the URL to your NocoDB view where the map data is stored.
NOCODB_VIEW_URL=
# NOCODB_LOGIN_SHEET is the URL to your NocoDB login sheet.
NOCODB_LOGIN_SHEET=
# NOCODB_SETTINGS_SHEET is the URL to your NocoDB settings sheet.
NOCODB_SETTINGS_SHEET=
# NOCODB_SHIFTS_SHEET is the urls to your shifts sheets.
NOCODB_SHIFTS_SHEET=
# NOCODB_SHIFT_SIGNUPS_SHEET is the URL to your NocoDB shift signups sheet where users can add their own shifts.
NOCODB_SHIFT_SIGNUPS_SHEET=
# NOCODB_CUTS_SHEET is the URL to your Nocodb Cuts sheet.
NOCODB_CUTS_SHEET=
DOMAIN=$new_domain
# MkDocs Integration
MKDOCS_URL=https://$new_domain
MKDOCS_SEARCH_URL=https://$new_domain
MKDOCS_SITE_SERVER_PORT=4002
# Server Configuration
PORT=3000
NODE_ENV=production
# Session Secret (IMPORTANT: Generate a secure random string for production)
# You can generate one with: openssl rand -hex 32
SESSION_SECRET=$(openssl rand -hex 32 2>/dev/null || echo "changeme")
# Map Defaults (Edmonton, Alberta, Canada)
DEFAULT_LAT=53.5461
DEFAULT_LNG=-113.4938
DEFAULT_ZOOM=11
# Optional: Map Boundaries (prevents users from adding points outside area)
# BOUND_NORTH=53.7
# BOUND_SOUTH=53.4
# BOUND_EAST=-113.3
# BOUND_WEST=-113.7
# Cloudflare Settings
TRUST_PROXY=true
COOKIE_DOMAIN=.$new_domain
# Update NODE_ENV to production for HTTPS
NODE_ENV=production
# Add allowed origin
ALLOWED_ORIGINS=https://map.$new_domain,http://localhost:3000
# SMTP Configuration
SMTP_HOST=smtp.protonmail.ch
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=changeme@$new_domain
SMTP_PASS=changeme
EMAIL_FROM_NAME="$new_domain Map"
EMAIL_FROM_ADDRESS=changeme@$new_domain
# App Configuration
APP_NAME="$new_domain Map"
# Listmonk Configuration
LISTMONK_API_URL=http://listmonk_app:9000/api
LISTMONK_USERNAME=changeme
LISTMONK_PASSWORD=changeme
LISTMONK_SYNC_ENABLED=true
LISTMONK_INITIAL_SYNC=true # Set to true only for first run to sync existing data
EOL
echo "Map .env file updated with domain: $new_domain"
}
# Function to sync ports from root .env to map .env
sync_map_ports() {
echo "Syncing ports from root .env to map configuration..."
@ -887,6 +1039,73 @@ EOL
return 0
}
# Function to check for port conflicts in existing .env file
check_port_conflicts() {
echo "Checking for port conflicts in existing configuration..."
# Get list of all used ports on system
local used_ports_list
used_ports_list=$(get_used_ports)
if [ $? -ne 0 ] || [ -z "$used_ports_list" ]; then
echo "Warning: Could not scan system ports. Skipping conflict check."
return 1
fi
# Check each configured port
local -a conflicts=()
local -a port_vars=(
"CODE_SERVER_PORT"
"LISTMONK_PORT"
"LISTMONK_DB_PORT"
"MKDOCS_PORT"
"MKDOCS_SITE_SERVER_PORT"
"N8N_PORT"
"NOCODB_PORT"
"HOMEPAGE_PORT"
"GITEA_WEB_PORT"
"GITEA_SSH_PORT"
"MAP_PORT"
"INFLUENCE_PORT"
"MINI_QR_PORT"
"REDIS_PORT"
"PROMETHEUS_PORT"
"GRAFANA_PORT"
"CADVISOR_PORT"
"NODE_EXPORTER_PORT"
"REDIS_EXPORTER_PORT"
"ALERTMANAGER_PORT"
"GOTIFY_PORT"
"MAILHOG_SMTP_PORT"
"MAILHOG_WEB_PORT"
)
for var in "${port_vars[@]}"; do
local port_value="${!var}"
if [ -n "$port_value" ] && ! is_port_available "$port_value" "$used_ports_list"; then
conflicts+=("$var=$port_value")
fi
done
if [ ${#conflicts[@]} -gt 0 ]; then
echo ""
echo "⚠️ WARNING: Port conflicts detected!"
echo "The following ports are already in use on your system:"
for conflict in "${conflicts[@]}"; do
echo " - $conflict"
done
echo ""
echo "You may need to:"
echo "1. Stop services using these ports, or"
echo "2. Edit .env file to use different ports"
echo ""
else
echo "✅ No port conflicts detected!"
fi
return 0
}
# Function to get instance identifier
get_instance_identifier() {
# Try to get from directory name first
@ -900,14 +1119,15 @@ get_instance_identifier() {
default_instance="$dir_name"
fi
echo ""
echo "=== Instance Configuration ==="
echo "To run multiple Changemaker instances on the same machine,"
echo "each instance needs a unique identifier for containers and networks."
echo ""
# Send informational messages to stderr so they don't get captured
echo "" >&2
echo "=== Instance Configuration ===" >&2
echo "To run multiple Changemaker instances on the same machine," >&2
echo "each instance needs a unique identifier for containers and networks." >&2
echo "" >&2
if [ -n "$default_instance" ]; then
echo "Detected potential instance name from directory: $default_instance"
echo "Detected potential instance name from directory: $default_instance" >&2
read -p "Use this instance identifier? [Y/n]: " use_detected
if [[ ! "$use_detected" =~ ^[Nn]$ ]]; then
echo "$default_instance"
@ -929,6 +1149,7 @@ get_instance_identifier() {
instance_id="main"
fi
# Only output the final instance_id to stdout (this gets captured)
echo "$instance_id"
}
@ -941,6 +1162,13 @@ update_docker_compose_names() {
return 0
fi
# Check if docker-compose.yml exists and is not empty
if [ ! -f "$DOCKER_COMPOSE_FILE" ] || [ ! -s "$DOCKER_COMPOSE_FILE" ]; then
echo "Error: docker-compose.yml does not exist or is empty at: $DOCKER_COMPOSE_FILE"
echo "Please ensure docker-compose.yml exists before running this script."
return 1
fi
echo "Updating docker-compose.yml with instance identifier: $instance_id"
# Create a backup of the docker-compose.yml file
@ -952,25 +1180,84 @@ update_docker_compose_names() {
# Create temporary file for modifications
local temp_file=$(mktemp)
# Verify temp file was created
if [ ! -f "$temp_file" ]; then
echo "Error: Could not create temporary file"
return 1
fi
# Update container names, network names, and volume names
sed -e "s/container_name: \([^-]*\)$/container_name: \1-${instance_id}/g" \
# Process the file and save to temp file
# These patterns handle both clean files and files that already have instance suffixes
sed \
-e "s/container_name: code-server-changemaker-[a-zA-Z0-9_-]*$/container_name: code-server-changemaker-${instance_id}/g" \
-e "s/container_name: code-server-changemaker$/container_name: code-server-changemaker-${instance_id}/g" \
-e "s/container_name: mkdocs-site-server-changemaker-[a-zA-Z0-9_-]*$/container_name: mkdocs-site-server-changemaker-${instance_id}/g" \
-e "s/container_name: mkdocs-site-server-changemaker$/container_name: mkdocs-site-server-changemaker-${instance_id}/g" \
-e "s/container_name: \([^-]*\)-changemaker-[a-zA-Z0-9_-]*$/container_name: \1-changemaker-${instance_id}/g" \
-e "s/container_name: \([^-]*\)-changemaker$/container_name: \1-changemaker-${instance_id}/g" \
-e "s/container_name: \([^-]*\)_\([^-]*\)$/container_name: \1_\2_${instance_id}/g" \
-e "s/container_name: \([^-]*\)_\([^-]*\)_changemaker$/container_name: \1_\2_changemaker_${instance_id}/g" \
-e "s/networks:/networks:/g" \
-e "s/container_name: listmonk_app_[^_]*_[a-zA-Z0-9_-]*-[a-zA-Z0-9_-]*$/container_name: listmonk_app_${instance_id}_${instance_id}-${instance_id}/g" \
-e "s/container_name: listmonk_app$/container_name: listmonk_app_${instance_id}_${instance_id}-${instance_id}/g" \
-e "s/container_name: listmonk_db_[^_]*_[a-zA-Z0-9_-]*-[a-zA-Z0-9_-]*$/container_name: listmonk_db_${instance_id}_${instance_id}-${instance_id}/g" \
-e "s/container_name: listmonk_db$/container_name: listmonk_db_${instance_id}_${instance_id}-${instance_id}/g" \
-e "s/container_name: gitea_changemaker_[^_]*_[a-zA-Z0-9_-]*-[a-zA-Z0-9_-]*$/container_name: gitea_changemaker_${instance_id}_${instance_id}-${instance_id}/g" \
-e "s/container_name: gitea_app$/container_name: gitea_changemaker_${instance_id}_${instance_id}-${instance_id}/g" \
-e "s/container_name: gitea_mysql_changemaker_[^_]*_[^_]*_[a-zA-Z0-9_-]*-[a-zA-Z0-9_-]*$/container_name: gitea_mysql_changemaker_${instance_id}_${instance_id}_${instance_id}-${instance_id}/g" \
-e "s/container_name: gitea_mysql_changemaker$/container_name: gitea_mysql_changemaker_${instance_id}_${instance_id}_${instance_id}-${instance_id}/g" \
-e "s/container_name: \([^-]*\)-[a-zA-Z0-9_-]*$/container_name: \1-${instance_id}/g" \
-e "s/container_name: \([^-_]*\)$/container_name: \1-${instance_id}/g" \
-e "s/changemaker-lite-[a-zA-Z0-9_-]*:/changemaker-lite-${instance_id}:/g" \
-e "s/changemaker-lite:/changemaker-lite-${instance_id}:/g" \
-e "s/- changemaker-lite-[a-zA-Z0-9_-]*$/- changemaker-lite-${instance_id}/g" \
-e "s/- changemaker-lite$/- changemaker-lite-${instance_id}/g" \
-e "s/driver: bridge$/driver: bridge/g" \
-e "s/volumes:/volumes:/g" \
-e "s/listmonk-data-[a-zA-Z0-9_-]*:/listmonk-data-${instance_id}:/g" \
-e "s/listmonk-data:/listmonk-data-${instance_id}:/g" \
-e "s/source: listmonk-data-[a-zA-Z0-9_-]*$/source: listmonk-data-${instance_id}/g" \
-e "s/source: listmonk-data$/source: listmonk-data-${instance_id}/g" \
-e "s/n8n_data_[a-zA-Z0-9_-]*:/n8n_data_${instance_id}:/g" \
-e "s/n8n_data:/n8n_data_${instance_id}:/g" \
-e "s/source: n8n_data_[a-zA-Z0-9_-]*$/source: n8n_data_${instance_id}/g" \
-e "s/source: n8n_data$/source: n8n_data_${instance_id}/g" \
-e "s/nc_data_[a-zA-Z0-9_-]*:/nc_data_${instance_id}:/g" \
-e "s/nc_data:/nc_data_${instance_id}:/g" \
-e "s/source: nc_data_[a-zA-Z0-9_-]*$/source: nc_data_${instance_id}/g" \
-e "s/source: nc_data$/source: nc_data_${instance_id}/g" \
-e "s/db_data_[a-zA-Z0-9_-]*:/db_data_${instance_id}:/g" \
-e "s/db_data:/db_data_${instance_id}:/g" \
-e "s/source: db_data_[a-zA-Z0-9_-]*$/source: db_data_${instance_id}/g" \
-e "s/source: db_data$/source: db_data_${instance_id}/g" \
-e "s/gitea_data_[a-zA-Z0-9_-]*:/gitea_data_${instance_id}:/g" \
-e "s/gitea_data:/gitea_data_${instance_id}:/g" \
-e "s/source: gitea_data_[a-zA-Z0-9_-]*$/source: gitea_data_${instance_id}/g" \
-e "s/source: gitea_data$/source: gitea_data_${instance_id}/g" \
-e "s/mysql_data_[a-zA-Z0-9_-]*:/mysql_data_${instance_id}:/g" \
-e "s/mysql_data:/mysql_data_${instance_id}:/g" \
-e "s/source: mysql_data_[a-zA-Z0-9_-]*$/source: mysql_data_${instance_id}/g" \
-e "s/source: mysql_data$/source: mysql_data_${instance_id}/g" \
-e "s/redis-data-[a-zA-Z0-9_-]*:/redis-data-${instance_id}:/g" \
-e "s/redis-data:/redis-data-${instance_id}:/g" \
-e "s/source: redis-data-[a-zA-Z0-9_-]*$/source: redis-data-${instance_id}/g" \
-e "s/source: redis-data$/source: redis-data-${instance_id}/g" \
-e "s/prometheus-data-[a-zA-Z0-9_-]*:/prometheus-data-${instance_id}:/g" \
-e "s/prometheus-data:/prometheus-data-${instance_id}:/g" \
-e "s/source: prometheus-data-[a-zA-Z0-9_-]*$/source: prometheus-data-${instance_id}/g" \
-e "s/source: prometheus-data$/source: prometheus-data-${instance_id}/g" \
-e "s/grafana-data-[a-zA-Z0-9_-]*:/grafana-data-${instance_id}:/g" \
-e "s/grafana-data:/grafana-data-${instance_id}:/g" \
-e "s/source: grafana-data-[a-zA-Z0-9_-]*$/source: grafana-data-${instance_id}/g" \
-e "s/source: grafana-data$/source: grafana-data-${instance_id}/g" \
"$DOCKER_COMPOSE_FILE" > "$temp_file"
# Replace the original file
# Check if temp file has content
if [ ! -s "$temp_file" ]; then
echo "Error: sed operation produced empty file"
echo "Restoring from backup..."
cp "$backup_file" "$DOCKER_COMPOSE_FILE"
rm -f "$temp_file"
return 1
fi
# Replace the original file only if temp file has content
mv "$temp_file" "$DOCKER_COMPOSE_FILE"
echo "✅ Updated docker-compose.yml with instance-specific names:"
@ -1018,10 +1305,25 @@ echo "Please provide the following information:"
# Get instance identifier and update docker-compose.yml
echo -e "\n---- Instance Configuration ----"
instance_identifier=$(get_instance_identifier)
update_docker_compose_names "$instance_identifier"
# Strip any whitespace/newlines from the captured value
instance_identifier=$(echo "$instance_identifier" | tr -d '\n\r' | xargs)
# Only update docker-compose.yml if we have a non-default instance ID
if [ -n "$instance_identifier" ] && [ "$instance_identifier" != "main" ]; then
if update_docker_compose_names "$instance_identifier"; then
echo "✅ Docker Compose configuration updated successfully"
else
echo "⚠️ Warning: Failed to update docker-compose.yml"
echo " Continuing with default configuration..."
fi
else
echo "Using default instance configuration (no modifications to docker-compose.yml)"
fi
update_env_instance_config "$instance_identifier"
# Domain configuration
echo -e "\n---- Domain Configuration ----"
read -p "Enter your domain name (without protocol, e.g., example.com): " domain_name
if [ -z "$domain_name" ]; then
@ -1029,6 +1331,20 @@ if [ -z "$domain_name" ]; then
domain_name="changeme.org"
fi
# Local IP configuration for Homepage Local tab
echo -e "\n---- Local Network Configuration ----"
echo "For the Homepage dashboard, you can configure the Local tab to use a specific"
echo "IP address for accessing services on your local network (e.g., Tailscale IP)."
echo ""
read -p "Enter local IP address [default: localhost]: " local_ip
if [ -z "$local_ip" ]; then
local_ip="localhost"
echo "Using default: localhost"
else
echo "Using local IP: $local_ip"
fi
echo -e "\nUpdating domain settings in .env file..."
# Update main domain settings
@ -1045,6 +1361,9 @@ update_env_var "GITEA_ROOT_URL" "https://git.$domain_name"
update_env_var "COOKIE_DOMAIN" ".$domain_name"
update_env_var "ALLOWED_ORIGINS" "https://map.$domain_name,http://localhost:3000"
# Update alert email to use new domain
update_env_var "ALERT_EMAIL_TO" "admin@$domain_name"
echo "Domain settings updated successfully!"
# Update the map's .env file
@ -1103,7 +1422,7 @@ update_mkdocs_yml "$domain_name"
# Update service URLs in services.yaml
echo -e "\nUpdating service URLs in services.yaml..."
update_services_yaml "$domain_name"
update_services_yaml "$domain_name" "$local_ip"
# Update the login URL in main.html
echo -e "\nUpdating login URL in main.html..."
@ -1173,19 +1492,37 @@ update_env_var "GITEA_DB_PASSWD" "$gitea_db_password"
gitea_db_root_password=$(generate_password 20)
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"
# Generate and update Gotify admin password
gotify_admin_password=$(generate_password 20)
update_env_var "GOTIFY_ADMIN_PASSWORD" "$gotify_admin_password"
echo "Secure passwords generated and updated."
# Fix container directory permissions before finishing
fix_container_permissions
echo -e "\n✅ Configuration completed successfully!"
echo "Your .env file has been configured with:"
echo "- Instance ID: $instance_identifier"
echo "- Domain: $domain_name"
echo "- Local IP: $local_ip (for Homepage Local tab)"
echo "- Cookie Domain: .$domain_name"
echo "- Allowed Origins: https://map.$domain_name,http://localhost:3000"
echo "- Map .env updated with domain settings"
echo "- Listmonk Admin: $listmonk_user"
echo "- N8N Admin Email: $n8n_email"
echo "- Secure random passwords for database, encryption, and NocoDB"
echo "- Grafana Admin Password: Generated (see .env file)"
echo "- Gotify Admin Password: Generated (see .env file)"
echo "- Centralized services: Redis, Prometheus, Grafana"
echo "- Monitoring services: cAdvisor, Node Exporter, Redis Exporter, Alertmanager, Gotify"
echo "- Email testing: MailHog"
echo "- Tunnel configuration updated at: $TUNNEL_CONFIG_FILE"
echo "- Homepage services.yaml configured with Production and Local tabs"
echo -e "\nYour .env file is located at: $ENV_FILE"
echo "A backup of your original .env file was created before modifications."
@ -1213,6 +1550,29 @@ echo " - Map: http://localhost:${MAP_PORT:-3000}"
echo " - Influence: http://localhost:${INFLUENCE_PORT:-3333}"
echo " - Mini QR: http://localhost:${MINI_QR_PORT:-8089}"
echo ""
echo " Centralized Services:"
echo " - Redis: http://localhost:${REDIS_PORT:-6379}"
echo ""
echo " Email Testing:"
echo " - MailHog Web UI: http://localhost:${MAILHOG_WEB_PORT:-8025}"
echo " - MailHog SMTP: localhost:${MAILHOG_SMTP_PORT:-1025}"
echo ""
echo " Monitoring Services (optional monitoring profile):"
echo " - Prometheus: http://localhost:${PROMETHEUS_PORT:-9090}"
echo " - Grafana: http://localhost:${GRAFANA_PORT:-3001}"
echo " - Alertmanager: http://localhost:${ALERTMANAGER_PORT:-9093}"
echo " - Gotify: http://localhost:${GOTIFY_PORT:-8889}"
echo " - cAdvisor: http://localhost:${CADVISOR_PORT:-8080}"
echo " - Node Exporter: http://localhost:${NODE_EXPORTER_PORT:-9100}/metrics"
echo " - Redis Exporter: http://localhost:${REDIS_EXPORTER_PORT:-9121}/metrics"
echo ""
echo " To start with monitoring:"
echo " docker compose --profile monitoring up -d"
echo ""
echo " Monitoring Credentials:"
echo " - Grafana: admin / (check .env: GRAFANA_ADMIN_PASSWORD)"
echo " - Gotify: admin / (check .env: GOTIFY_ADMIN_PASSWORD)"
echo ""
echo "3. When ready for production:"
echo " ./start-production.sh"
echo ""

View File

@ -0,0 +1,100 @@
global:
resolve_timeout: 5m
# SMTP configuration for email alerts (fallback)
# Using MailHog for development - update for production
smtp_from: 'alerts@changemaker.local'
smtp_smarthost: 'mailhog:1025'
smtp_auth_username: ''
smtp_auth_password: ''
smtp_require_tls: false
# Templates for notification content
templates:
- '/etc/alertmanager/*.tmpl'
# Route alerts to appropriate receivers based on severity
route:
group_by: ['alertname', 'cluster', 'service']
group_wait: 10s
group_interval: 10s
repeat_interval: 12h
receiver: 'default'
routes:
# Critical alerts go to both Gotify and email
- match:
severity: critical
receiver: 'critical-alerts'
group_wait: 0s
group_interval: 5m
repeat_interval: 4h
# Warning alerts go to Gotify only
- match:
severity: warning
receiver: 'warning-alerts'
group_wait: 30s
repeat_interval: 12h
# Info alerts (rate limiting, etc.) - Gotify with lower priority
- match:
severity: info
receiver: 'info-alerts'
repeat_interval: 24h
# Alert receivers
receivers:
# Default receiver (catches all unmatched)
# Note: Configure GOTIFY_APP_TOKEN in .env and update this file for Gotify to work
- name: 'default'
email_configs:
- to: 'admin@changemaker.local'
headers:
Subject: '[Changemaker] {{ .GroupLabels.alertname }}'
# Critical alerts - email only (configure Gotify token for push notifications)
- name: 'critical-alerts'
email_configs:
- to: 'admin@changemaker.local'
headers:
Subject: '🚨 CRITICAL Alert: {{ .GroupLabels.alertname }}'
html: |
<h2 style="color: #d32f2f;">Critical Alert Triggered</h2>
{{ range .Alerts }}
<p><strong>Alert:</strong> {{ .Labels.alertname }}</p>
<p><strong>Severity:</strong> {{ .Labels.severity }}</p>
<p><strong>Summary:</strong> {{ .Annotations.summary }}</p>
<p><strong>Description:</strong> {{ .Annotations.description }}</p>
<p><strong>Started:</strong> {{ .StartsAt }}</p>
<hr>
{{ end }}
# Warning alerts - email only
- name: 'warning-alerts'
email_configs:
- to: 'admin@changemaker.local'
headers:
Subject: '⚠️ Warning: {{ .GroupLabels.alertname }}'
# Info alerts - email only
- name: 'info-alerts'
email_configs:
- to: 'admin@changemaker.local'
headers:
Subject: ' Info: {{ .GroupLabels.alertname }}'
# Inhibition rules (prevent spam)
inhibit_rules:
# If a critical alert is firing, suppress related warnings
- source_match:
severity: 'critical'
target_match:
severity: 'warning'
equal: ['alertname', 'instance']
# If disk is critical, suppress disk warning
- source_match:
alertname: 'DiskSpaceCritical'
target_match:
alertname: 'DiskSpaceLow'
equal: ['instance']

View File

@ -1,29 +1,29 @@
# Cloudflare Tunnel Configuration for cmlite.org
# Generated by Changemaker.lite start-production.sh on Sat Jul 5 09:07:25 PM MDT 2025
# Cloudflare Tunnel Configuration for freealberta.org
# Generated by Changemaker.lite start-production.sh on Mon Jan 12 01:31:30 PM MST 2026
tunnel: 0447884a-8052-41fa-9ff1-f6d16abdc5e1
credentials-file: /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/configs/cloudflare/0447884a-8052-41fa-9ff1-f6d16abdc5e1.json
tunnel: 5c56e1b0-5a58-4bf3-96ea-517400ba55f3
credentials-file: /home/bunker-admin/freealberta/changemaker.lite/configs/cloudflare/5c56e1b0-5a58-4bf3-96ea-517400ba55f3.json
ingress:
- hostname: homepage.cmlite.org
service: http://localhost:3010
- hostname: code.cmlite.org
service: http://localhost:8888
- hostname: listmonk.cmlite.org
- hostname: homepage.freealberta.org
service: http://localhost:3011
- hostname: code.freealberta.org
service: http://localhost:8889
- hostname: listmonk.freealberta.org
service: http://localhost:9001
- hostname: docs.cmlite.org
service: http://localhost:4000
- hostname: cmlite.org
- hostname: docs.freealberta.org
service: http://localhost:4002
- hostname: n8n.cmlite.org
service: http://localhost:5678
- hostname: db.cmlite.org
service: http://localhost:8090
- hostname: git.cmlite.org
service: http://localhost:3030
- hostname: map.cmlite.org
service: http://localhost:3000
- hostname: qr.cmlite.org
service: http://localhost:8089
- hostname: influence.cmlite.org
- hostname: freealberta.org
service: http://localhost:4003
- hostname: n8n.freealberta.org
service: http://localhost:5679
- hostname: db.freealberta.org
service: http://localhost:8092
- hostname: git.freealberta.org
service: http://localhost:3031
- hostname: map.freealberta.org
service: http://localhost:3002
- hostname: influence.freealberta.org
service: http://localhost:3333
- hostname: qr.freealberta.org
service: http://localhost:8091
- service: http_status:404

0
configs/code-server/.config/.gitkeep Normal file → Executable file
View File

0
configs/code-server/.local/.gitkeep Normal file → Executable file
View File

View File

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

View File

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

View File

@ -0,0 +1,509 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"id": null,
"links": [],
"liveNow": false,
"panels": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [
{
"options": {
"0": {
"color": "red",
"index": 1,
"text": "DOWN"
},
"1": {
"color": "green",
"index": 0,
"text": "UP"
}
},
"type": "value"
}
],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "red",
"value": null
},
{
"color": "green",
"value": 1
}
]
},
"unit": "short"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 24,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"orientation": "auto",
"reduceOptions": {
"values": false,
"calcs": [
"lastNotNull"
],
"fields": ""
},
"showThresholdLabels": false,
"showThresholdMarkers": true,
"text": {}
},
"pluginVersion": "10.0.0",
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "up{job=\"influence-app\"}",
"legendFormat": "Influence App",
"refId": "A"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "redis_up",
"legendFormat": "Redis",
"refId": "B"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "up{job=\"node\"}",
"legendFormat": "System Node",
"refId": "C"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "up{job=\"prometheus\"}",
"legendFormat": "Prometheus",
"refId": "D"
}
],
"title": "Service Status",
"type": "gauge"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"tooltip": false,
"viz": false,
"legend": false
},
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "percent"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 8
},
"id": 2,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "100 - (avg by(instance) (rate(node_cpu_seconds_total{mode=\"idle\"}[5m])) * 100)",
"legendFormat": "CPU Usage",
"refId": "A"
}
],
"title": "CPU Usage",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"tooltip": false,
"viz": false,
"legend": false
},
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "percent"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 8
},
"id": 3,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100",
"legendFormat": "Memory Usage",
"refId": "A"
}
],
"title": "Memory Usage",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"tooltip": false,
"viz": false,
"legend": false
},
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "red",
"value": 80
}
]
},
"unit": "percent"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 0,
"y": 16
},
"id": 4,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "(1 - (node_filesystem_avail_bytes{mountpoint=\"/\"} / node_filesystem_size_bytes{mountpoint=\"/\"})) * 100",
"legendFormat": "Disk Usage",
"refId": "A"
}
],
"title": "Disk Usage",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 10,
"gradientMode": "none",
"hideFrom": {
"tooltip": false,
"viz": false,
"legend": false
},
"lineInterpolation": "linear",
"lineWidth": 1,
"pointSize": 5,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
}
]
},
"unit": "reqps"
},
"overrides": []
},
"gridPos": {
"h": 8,
"w": 12,
"x": 12,
"y": 16
},
"id": 5,
"options": {
"legend": {
"calcs": [],
"displayMode": "list",
"placement": "bottom",
"showLegend": true
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "prometheus"
},
"expr": "rate(influence_http_requests_total[5m])",
"legendFormat": "Requests/sec",
"refId": "A"
}
],
"title": "HTTP Request Rate",
"type": "timeseries"
}
],
"refresh": "10s",
"schemaVersion": 38,
"style": "dark",
"tags": ["changemaker", "system-health"],
"templating": {
"list": []
},
"time": {
"from": "now-1h",
"to": "now"
},
"timepicker": {},
"timezone": "",
"title": "Changemaker System Health",
"uid": "changemaker-system-health",
"version": 1,
"weekStart": ""
}

1828
configs/homepage/logs/homepage.log Normal file → Executable file

File diff suppressed because it is too large Load Diff

5
configs/homepage/proxmox.yaml Executable file
View File

@ -0,0 +1,5 @@
---
# pve:
# url: https://proxmox.host.or.ip:8006
# token: username@pam!Token ID
# secret: secret

View File

@ -1,70 +1,77 @@
---
# For public access, replace "http://localhost" with your subdomain URLs
# Homepage Services Configuration
# Tab 1: Production URLs (external/public access)
# Tab 2: Local Development URLs (localhost with ports from config.sh)
- Essential Tools:
#####################################
# PRODUCTION - External URLs
#####################################
- Production - Essential Tools:
- Code Server:
icon: mdi-code-braces
href: "https://code.cmlite.org"
href: "https://code.freealberta.org"
description: VS Code in the browser - Platform Editor
container: code-server-changemaker
container: code-server-changemaker-freealberta
- NocoDB:
icon: mdi-database
href: "https://db.cmlite.org"
href: "https://db.freealberta.org"
description: No-code database platform
container: changemakerlite-nocodb-1
- Map Server:
icon: mdi-map
href: "https://map.cmlite.org"
description: Map server for geospatial data
container: nocodb-map-viewer
- Production - Content & Docs:
- Influence:
icon: mdi-account-group
href: "https://influence.cmlite.org"
description: Political influence and campaign management
container: influence-app-1
- Content & Documentation:
- Main Site:
icon: mdi-web
href: "https://cmlite.org"
href: "https://freealberta.org"
description: CM-lite campaign website
container: mkdocs-site-server-changemaker
container: mkdocs-site-server-changemaker-freealberta
- MkDocs (Live):
icon: mdi-book-open-page-variant
href: "https://docs.cmlite.org"
href: "https://docs.freealberta.org"
description: Live documentation server with hot reload
container: mkdocs-changemaker
container: mkdocs-changemaker-freealberta
- Mini QR:
icon: mdi-qrcode
href: "https://qr.cmlite.org"
href: "https://qr.freealberta.org"
description: QR code generator
container: mini-qr
container: mini-qr-freealberta
- Listmonk:
icon: mdi-email-newsletter
href: "https://listmonk.cmlite.org"
href: "https://listmonk.freealberta.org"
description: Newsletter & mailing list manager
container: listmonk_app
container: listmonk_app_test4_freealberta-freealberta
- MailHog:
icon: mdi-email-open
href: "https://mail.freealberta.org"
description: Email testing service
container: mailhog-changemaker-freealberta
- Production - Automation:
- Automation & Infrastructure:
- n8n:
icon: mdi-robot-industrial
href: "https://n8n.cmlite.org"
href: "https://n8n.freealberta.org"
description: Workflow automation platform
container: n8n-changemaker
container: n8n-changemaker-freealberta
- Gitea:
icon: mdi-git
href: "https://git.freealberta.org"
description: Git repository hosting
container: gitea_changemaker_test4_freealberta-freealberta
- PostgreSQL (Listmonk):
icon: mdi-database-outline
href: "#"
description: Database for Listmonk
container: listmonk_db
container: listmonk_db_test4_freealberta-freealberta
- PostgreSQL (NocoDB):
icon: mdi-database-outline
@ -72,8 +79,184 @@
description: Database for NocoDB
container: changemakerlite-root_db-1
- Redis:
icon: mdi-database-sync
href: "#"
description: Shared cache & session storage
container: redis-changemaker-freealberta
- Production - Monitoring:
- Prometheus:
icon: mdi-chart-line
href: "https://prometheus.freealberta.org"
description: Metrics collection & time-series database
container: prometheus-changemaker-freealberta
- Grafana:
icon: mdi-chart-box
href: "https://grafana.freealberta.org"
description: Monitoring dashboards & visualizations
container: grafana-changemaker-freealberta
- Alertmanager:
icon: mdi-bell-alert
href: "https://alertmanager.freealberta.org"
description: Alert routing & notification management
container: alertmanager-changemaker-freealberta
- Gotify:
icon: mdi-cellphone-message
href: "https://gotify.freealberta.org"
description: Self-hosted push notifications
container: gotify-changemaker-freealberta
- cAdvisor:
icon: mdi-docker
href: "https://cadvisor.freealberta.org"
description: Container resource metrics
container: cadvisor-changemaker-freealberta
- Node Exporter:
icon: mdi-server
href: "#"
description: System-level metrics exporter
container: node-exporter-changemaker-freealberta
- Redis Exporter:
icon: mdi-database-export
href: "#"
description: Redis metrics exporter
container: redis-exporter-changemaker-freealberta
#####################################
# LOCAL DEVELOPMENT - Localhost URLs
#####################################
- Local - Essential Tools:
- Code Server:
icon: mdi-code-braces
href: "http://100.67.78.53:8888"
description: VS Code in the browser (port 8888)
container: code-server-changemaker-freealberta
- NocoDB:
icon: mdi-database
href: "http://100.67.78.53:8090"
description: No-code database platform (port 8090)
container: changemakerlite-nocodb-1
- Homepage:
icon: mdi-home
href: "http://100.67.78.53:3010"
description: This dashboard (port 3010)
container: homepage-changemaker-freealberta
- Local - Content & Docs:
- Main Site:
icon: mdi-web
href: "http://100.67.78.53:4001"
description: CM-lite campaign website (port 4001)
container: mkdocs-site-server-changemaker-freealberta
- MkDocs (Live):
icon: mdi-book-open-page-variant
href: "http://100.67.78.53:4000"
description: Live documentation with hot reload (port 4000)
container: mkdocs-changemaker-freealberta
- Mini QR:
icon: mdi-qrcode
href: "http://100.67.78.53:8089"
description: QR code generator (port 8089)
container: mini-qr-freealberta
- Listmonk:
icon: mdi-email-newsletter
href: "http://100.67.78.53:9001"
description: Newsletter & mailing list manager (port 9001)
container: listmonk_app_test4_freealberta-freealberta
- MailHog:
icon: mdi-email-open
href: "http://100.67.78.53:8025"
description: Email testing service (port 8025)
container: mailhog-changemaker-freealberta
- Local - Automation:
- n8n:
icon: mdi-robot-industrial
href: "http://100.67.78.53:5678"
description: Workflow automation platform (port 5678)
container: n8n-changemaker-freealberta
- Gitea:
icon: mdi-git
href: "https://git.cmlite.org"
description: Git repository hosting
container: gitea_changemaker
href: "http://100.67.78.53:3030"
description: Git repository hosting (port 3030)
container: gitea_changemaker_test4_freealberta-freealberta
- PostgreSQL (Listmonk):
icon: mdi-database-outline
href: "#"
description: Database for Listmonk (port 5432)
container: listmonk_db_test4_freealberta-freealberta
- PostgreSQL (NocoDB):
icon: mdi-database-outline
href: "#"
description: Database for NocoDB
container: changemakerlite-root_db-1
- Redis:
icon: mdi-database-sync
href: "#"
description: Shared cache & session storage (port 6379)
container: redis-changemaker-freealberta
- Local - Monitoring:
- Prometheus:
icon: mdi-chart-line
href: "http://100.67.78.53:9090"
description: Metrics collection & time-series database (port 9090)
container: prometheus-changemaker-freealberta
- Grafana:
icon: mdi-chart-box
href: "http://100.67.78.53:3001"
description: Monitoring dashboards & visualizations (port 3001)
container: grafana-changemaker-freealberta
- Alertmanager:
icon: mdi-bell-alert
href: "http://100.67.78.53:9093"
description: Alert routing & notification management (port 9093)
container: alertmanager-changemaker-freealberta
- Gotify:
icon: mdi-cellphone-message
href: "http://100.67.78.53:8889"
description: Self-hosted push notifications (port 8889)
container: gotify-changemaker-freealberta
- cAdvisor:
icon: mdi-docker
href: "http://100.67.78.53:8080"
description: Container resource metrics (port 8080)
container: cadvisor-changemaker-freealberta
- Node Exporter:
icon: mdi-server
href: "http://100.67.78.53:9100/metrics"
description: System-level metrics exporter (port 9100)
container: node-exporter-changemaker-freealberta
- Redis Exporter:
icon: mdi-database-export
href: "http://100.67.78.53:9121/metrics"
description: Redis metrics exporter (port 9121)
container: redis-exporter-changemaker-freealberta

View File

@ -0,0 +1,274 @@
---
# Homepage Services Configuration
# Tab 1: Production URLs (external/public access)
# Tab 2: Local Development URLs (localhost with ports from config.sh)
#####################################
# PRODUCTION - External URLs
#####################################
- Production - Essential Tools:
- Code Server:
icon: mdi-code-braces
href: "https://code.bnkserve.org"
description: VS Code in the browser - Platform Editor
container: code-server-changemaker
- NocoDB:
icon: mdi-database
href: "https://db.bnkserve.org"
description: No-code database platform
container: changemakerlite-nocodb-1
- Map Server:
icon: mdi-map
href: "https://map.bnkserve.org"
description: Map server for geospatial data
container: nocodb-map-viewer
- Influence:
icon: mdi-account-group
href: "https://influence.bnkserve.org"
description: Political influence and campaign management
container: influence-app-1
- Production - Content & Docs:
- Main Site:
icon: mdi-web
href: "https://bnkserve.org"
description: CM-lite campaign website
container: mkdocs-site-server-changemaker
- MkDocs (Live):
icon: mdi-book-open-page-variant
href: "https://docs.cmlite.org"
description: Live documentation server with hot reload
container: mkdocs-changemaker
- Mini QR:
icon: mdi-qrcode
href: "https://qr.bnkserve.org"
description: QR code generator
container: mini-qr
- Listmonk:
icon: mdi-email-newsletter
href: "https://listmonk.bnkserve.org"
description: Newsletter & mailing list manager
container: listmonk_app
- Production - Automation:
- n8n:
icon: mdi-robot-industrial
href: "https://n8n.bnkserve.org"
description: Workflow automation platform
container: n8n-changemaker
- Gitea:
icon: mdi-git
href: "https://git.bnkserve.org"
description: Git repository hosting
container: gitea_changemaker
- PostgreSQL (Listmonk):
icon: mdi-database-outline
href: "#"
description: Database for Listmonk
container: listmonk_db
- PostgreSQL (NocoDB):
icon: mdi-database-outline
href: "#"
description: Database for NocoDB
container: changemakerlite-root_db-1
- Redis:
icon: mdi-database-sync
href: "#"
description: Shared cache & session storage
container: redis-changemaker
- Production - Monitoring:
- Prometheus:
icon: mdi-chart-line
href: "https://prometheus.bnkserve.org"
description: Metrics collection & time-series database
container: prometheus-changemaker
- Grafana:
icon: mdi-chart-box
href: "https://grafana.bnkserve.org"
description: Monitoring dashboards & visualizations
container: grafana-changemaker
- Alertmanager:
icon: mdi-bell-alert
href: "https://alertmanager.bnkserve.org"
description: Alert routing & notification management
container: alertmanager-changemaker
- Gotify:
icon: mdi-cellphone-message
href: "https://gotify.bnkserve.org"
description: Self-hosted push notifications
container: gotify-changemaker
- cAdvisor:
icon: mdi-docker
href: "https://cadvisor.bnkserve.org"
description: Container resource metrics
container: cadvisor-changemaker
- Node Exporter:
icon: mdi-server
href: "#"
description: System-level metrics exporter
container: node-exporter-changemaker
- Redis Exporter:
icon: mdi-database-export
href: "#"
description: Redis metrics exporter
container: redis-exporter-changemaker
#####################################
# LOCAL DEVELOPMENT - Localhost URLs
#####################################
- Local - Essential Tools:
- Code Server:
icon: mdi-code-braces
href: "http://localhost:8888"
description: VS Code in the browser (port 8888)
container: code-server-changemaker
- NocoDB:
icon: mdi-database
href: "http://localhost:8090"
description: No-code database platform (port 8090)
container: changemakerlite-nocodb-1
- Map Server:
icon: mdi-map
href: "http://localhost:3000"
description: Map server for geospatial data (port 3000)
container: nocodb-map-viewer
- Influence:
icon: mdi-account-group
href: "http://localhost:3333"
description: Political influence and campaign management (port 3333)
container: influence-app-1
- Homepage:
icon: mdi-home
href: "http://localhost:3010"
description: This dashboard (port 3010)
container: homepage-changemaker
- Local - Content & Docs:
- Main Site:
icon: mdi-web
href: "http://localhost:4001"
description: CM-lite campaign website (port 4001)
container: mkdocs-site-server-changemaker
- MkDocs (Live):
icon: mdi-book-open-page-variant
href: "http://localhost:4000"
description: Live documentation with hot reload (port 4000)
container: mkdocs-changemaker
- Mini QR:
icon: mdi-qrcode
href: "http://localhost:8089"
description: QR code generator (port 8089)
container: mini-qr
- Listmonk:
icon: mdi-email-newsletter
href: "http://localhost:9000"
description: Newsletter & mailing list manager (port 9000)
container: listmonk_app
- Local - Automation:
- n8n:
icon: mdi-robot-industrial
href: "http://localhost:5678"
description: Workflow automation platform (port 5678)
container: n8n-changemaker
- Gitea:
icon: mdi-git
href: "http://localhost:3030"
description: Git repository hosting (port 3030)
container: gitea_changemaker
- PostgreSQL (Listmonk):
icon: mdi-database-outline
href: "#"
description: Database for Listmonk (port 5432)
container: listmonk_db
- PostgreSQL (NocoDB):
icon: mdi-database-outline
href: "#"
description: Database for NocoDB
container: changemakerlite-root_db-1
- Redis:
icon: mdi-database-sync
href: "#"
description: Shared cache & session storage (port 6379)
container: redis-changemaker
- Local - Monitoring:
- Prometheus:
icon: mdi-chart-line
href: "http://localhost:9090"
description: Metrics collection & time-series database (port 9090)
container: prometheus-changemaker
- Grafana:
icon: mdi-chart-box
href: "http://localhost:3001"
description: Monitoring dashboards & visualizations (port 3001)
container: grafana-changemaker
- Alertmanager:
icon: mdi-bell-alert
href: "http://localhost:9093"
description: Alert routing & notification management (port 9093)
container: alertmanager-changemaker
- Gotify:
icon: mdi-cellphone-message
href: "http://localhost:8889"
description: Self-hosted push notifications (port 8889)
container: gotify-changemaker
- cAdvisor:
icon: mdi-docker
href: "http://localhost:8080"
description: Container resource metrics (port 8080)
container: cadvisor-changemaker
- Node Exporter:
icon: mdi-server
href: "http://localhost:9100/metrics"
description: System-level metrics exporter (port 9100)
container: node-exporter-changemaker
- Redis Exporter:
icon: mdi-database-export
href: "http://localhost:9121/metrics"
description: Redis metrics exporter (port 9121)
container: redis-exporter-changemaker

View File

@ -18,8 +18,33 @@ cardBlur: xl # xs, md,
headerStyle: boxed
layout:
style: columns
columns: 3
# Production Tab Groups - displayed as vertical columns
Production - Essential Tools:
tab: Production
style: column
Production - Content & Docs:
tab: Production
style: column
Production - Automation:
tab: Production
style: column
Production - Monitoring:
tab: Production
style: column
# Local Development Tab Groups - displayed as vertical columns
Local - Essential Tools:
tab: Local
style: column
Local - Content & Docs:
tab: Local
style: column
Local - Automation:
tab: Local
style: column
Local - Monitoring:
tab: Local
style: column
docker:
widget:

0
configs/homepage/widgets.yaml Normal file → Executable file
View File

View File

@ -0,0 +1,235 @@
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 }}."
# System health alerts
- name: system_alerts
interval: 30s
rules:
# NocoDB unreachable
- alert: NocoDBUnreachable
expr: up{job="nocodb"} == 0
for: 2m
labels:
severity: critical
annotations:
summary: "NocoDB database is unreachable"
description: "NocoDB has been unreachable for more than 2 minutes. All database operations will fail."
# Redis down
- alert: RedisDown
expr: redis_up == 0
for: 1m
labels:
severity: critical
annotations:
summary: "Redis cache is down"
description: "Redis has been down for more than 1 minute. Caching and session management will fail."
# Disk space running low
- alert: DiskSpaceLow
expr: (node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) < 0.15
for: 5m
labels:
severity: warning
annotations:
summary: "Disk space is running low"
description: "Only {{ $value | humanizePercentage }} disk space remaining on root filesystem."
# Disk space critical
- alert: DiskSpaceCritical
expr: (node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) < 0.10
for: 2m
labels:
severity: critical
annotations:
summary: "CRITICAL: Disk space nearly exhausted"
description: "Only {{ $value | humanizePercentage }} disk space remaining! System may fail soon."
# High CPU usage
- alert: HighCPUUsage
expr: 100 - (avg by(instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 85
for: 10m
labels:
severity: warning
annotations:
summary: "High CPU usage detected"
description: "CPU usage is {{ $value }}% on {{ $labels.instance }}."
# Container CPU throttling
- alert: ContainerCPUThrottling
expr: rate(container_cpu_cfs_throttled_seconds_total[5m]) > 0.5
for: 5m
labels:
severity: warning
annotations:
summary: "Container is being CPU throttled"
description: "Container {{ $labels.name }} is experiencing CPU throttling."
# Container memory usage high
- alert: ContainerMemoryHigh
expr: (container_memory_usage_bytes / container_spec_memory_limit_bytes) > 0.90
for: 5m
labels:
severity: warning
annotations:
summary: "Container memory usage is high"
description: "Container {{ $labels.name }} is using {{ $value | humanizePercentage }} of its memory limit."
# Infrastructure alerts
- name: infrastructure_alerts
interval: 30s
rules:
# Prometheus scrape failures
- alert: PrometheusScrapeFailures
expr: rate(prometheus_target_scrapes_failed_total[5m]) > 0.1
for: 5m
labels:
severity: warning
annotations:
summary: "Prometheus scrape failures detected"
description: "Prometheus is failing to scrape {{ $labels.job }} target."
# Prometheus configuration reload failure
- alert: PrometheusConfigReloadFailed
expr: prometheus_config_last_reload_successful == 0
for: 1m
labels:
severity: warning
annotations:
summary: "Prometheus configuration reload failed"
description: "Prometheus failed to reload its configuration. Check prometheus logs."
# Alertmanager down
- alert: AlertmanagerDown
expr: up{job="alertmanager"} == 0
for: 2m
labels:
severity: critical
annotations:
summary: "Alertmanager is down"
description: "Alertmanager has been down for 2 minutes. Alerts will not be delivered!"
# Security alerts
- name: security_alerts
interval: 15s
rules:
# Possible DDoS attack
- alert: PossibleDDoSAttack
expr: rate(influence_http_requests_total[1m]) > 1000
for: 2m
labels:
severity: critical
annotations:
summary: "Possible DDoS attack detected"
description: "Receiving {{ $value }} requests per second for 2 minutes. This may be a DDoS attack."
# Sustained high traffic
- alert: SustainedHighTraffic
expr: rate(influence_http_requests_total[5m]) > 500
for: 10m
labels:
severity: warning
annotations:
summary: "Sustained high traffic detected"
description: "Receiving {{ $value }} requests per second for 10 minutes. Monitor for performance issues."
# Too many 4xx errors
- alert: HighClientErrorRate
expr: rate(influence_http_requests_total{status_code=~"4.."}[5m]) > 5
for: 5m
labels:
severity: warning
annotations:
summary: "High rate of 4xx client errors"
description: "Receiving {{ $value }} client errors per second. Check for broken links or API misuse."

View File

@ -0,0 +1,61 @@
global:
scrape_interval: 15s
evaluation_interval: 15s
external_labels:
monitor: 'changemaker-lite'
# Alertmanager configuration
alerting:
alertmanagers:
- static_configs:
- targets: ['alertmanager:9093']
# 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
- job_name: 'redis'
static_configs:
- targets: ['redis-exporter:9121']
scrape_interval: 15s
# cAdvisor - Docker container metrics
- job_name: 'cadvisor'
static_configs:
- targets: ['cadvisor:8080']
scrape_interval: 15s
# Node Exporter - System metrics
- job_name: 'node'
static_configs:
- targets: ['node-exporter:9100']
scrape_interval: 15s
# Prometheus self-monitoring
- job_name: 'prometheus'
static_configs:
- targets: ['localhost:9090']
# Alertmanager monitoring
- job_name: 'alertmanager'
static_configs:
- targets: ['alertmanager:9093']
scrape_interval: 30s

View File

@ -8,7 +8,7 @@ services:
build:
context: .
dockerfile: Dockerfile.code-server
container_name: code-server-changemaker
container_name: code-server-changemaker-freealberta
environment:
- DOCKER_USER=${USER_NAME:-coder}
- DEFAULT_WORKSPACE=/home/coder/mkdocs/
@ -21,16 +21,16 @@ services:
- "${CODE_SERVER_PORT:-8888}:8080"
restart: unless-stopped
networks:
- changemaker-lite
- changemaker-lite-freealberta
listmonk-app:
image: listmonk/listmonk:latest
container_name: listmonk_app
container_name: listmonk_app_test4_freealberta-freealberta
restart: unless-stopped
ports:
- "${LISTMONK_PORT:-9001}:9000"
networks:
- changemaker-lite
- changemaker-lite-freealberta
hostname: ${LISTMONK_HOSTNAME}
depends_on:
- listmonk-db
@ -41,7 +41,7 @@ services:
LISTMONK_db__password: *db-password
LISTMONK_db__database: *db-name
LISTMONK_db__host: listmonk-db
LISTMONK_db__port: ${LISTMONK_DB_PORT:-5432}
LISTMONK_db__port: 5432
LISTMONK_db__ssl_mode: disable
LISTMONK_db__max_open: ${LISTMONK_DB_MAX_OPEN:-25}
LISTMONK_db__max_idle: ${LISTMONK_DB_MAX_IDLE:-25}
@ -54,12 +54,12 @@ services:
listmonk-db:
image: postgres:17-alpine
container_name: listmonk_db
container_name: listmonk_db_test4_freealberta-freealberta
restart: unless-stopped
ports:
- "127.0.0.1:${LISTMONK_DB_PORT:-5432}:5432"
networks:
- changemaker-lite
- changemaker-lite-freealberta
environment:
<<: *db-credentials
healthcheck:
@ -69,12 +69,12 @@ services:
retries: 6
volumes:
- type: volume
source: listmonk-data
source: listmonk-data-freealberta
target: /var/lib/postgresql/data
mkdocs:
image: squidfunk/mkdocs-material
container_name: mkdocs-changemaker
container_name: mkdocs-changemaker-freealberta
volumes:
- ./mkdocs:/docs:rw
- ./assets/images:/docs/assets/images:rw
@ -85,12 +85,12 @@ services:
- SITE_URL=${BASE_DOMAIN:-https://changeme.org}
command: serve --dev-addr=0.0.0.0:8000 --watch-theme --livereload
networks:
- changemaker-lite
- changemaker-lite-freealberta
restart: unless-stopped
mkdocs-site-server:
image: lscr.io/linuxserver/nginx:latest
container_name: mkdocs-site-server-changemaker
container_name: mkdocs-site-server-changemaker-freealberta
environment:
- PUID=${USER_ID:-1000}
- PGID=${GROUP_ID:-1000}
@ -102,11 +102,11 @@ services:
- "${MKDOCS_SITE_SERVER_PORT:-4001}:80"
restart: unless-stopped
networks:
- changemaker-lite
- changemaker-lite-freealberta
n8n:
image: docker.n8n.io/n8nio/n8n
container_name: n8n-changemaker
container_name: n8n-changemaker-freealberta
restart: unless-stopped
ports:
- "${N8N_PORT:-5678}:5678"
@ -122,10 +122,10 @@ services:
- N8N_DEFAULT_USER_EMAIL=${N8N_USER_EMAIL:-admin@example.com}
- N8N_DEFAULT_USER_PASSWORD=${N8N_USER_PASSWORD:-changeMe}
volumes:
- n8n_data:/home/node/.n8n
- n8n_data_test4:/home/node/.n8n
- ./local-files:/files
networks:
- changemaker-lite
- changemaker-lite-freealberta
nocodb:
depends_on:
@ -138,9 +138,9 @@ services:
- "${NOCODB_PORT:-8090}:8080"
restart: always
volumes:
- "nc_data:/usr/app/data"
- "nc_data_test4:/usr/app/data"
networks:
- changemaker-lite
- changemaker-lite-freealberta
root_db:
environment:
POSTGRES_DB: root_db
@ -154,14 +154,14 @@ services:
image: postgres:16.6
restart: always
volumes:
- "db_data:/var/lib/postgresql/data"
- "db_data_test4:/var/lib/postgresql/data"
networks:
- changemaker-lite
- changemaker-lite-freealberta
# Homepage App
homepage-changemaker:
image: ghcr.io/gethomepage/homepage:latest
container_name: homepage-changemaker
container_name: homepage-changemaker-freealberta
ports:
- "${HOMEPAGE_PORT:-3010}:3000"
volumes:
@ -177,12 +177,12 @@ services:
- HOMEPAGE_VAR_BASE_URL=${HOMEPAGE_VAR_BASE_URL:-http://localhost}
restart: unless-stopped
networks:
- changemaker-lite
- changemaker-lite-freealberta
# Gitea - Git service
gitea-app:
image: gitea/gitea:1.23.7
container_name: gitea_changemaker
container_name: gitea_changemaker_test4_freealberta-freealberta
environment:
- USER_UID=${USER_ID:-1000}
- USER_GID=${GROUP_ID:-1000}
@ -201,9 +201,9 @@ services:
- GITEA__server__PROXY_ALLOW_SUBNET=0.0.0.0/0
restart: unless-stopped
networks:
- changemaker-lite
- changemaker-lite-freealberta
volumes:
- gitea_data:/data
- gitea_data_test4:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
@ -214,7 +214,7 @@ services:
gitea-db:
image: mysql:8
container_name: gitea_mysql_changemaker
container_name: gitea_mysql_changemaker_test4_test4_freealberta-freealberta
restart: unless-stopped
environment:
- MYSQL_ROOT_PASSWORD=${GITEA_DB_ROOT_PASSWORD}
@ -222,9 +222,9 @@ services:
- MYSQL_PASSWORD=${GITEA_DB_PASSWD}
- MYSQL_DATABASE=${GITEA_DB_NAME:-gitea}
networks:
- changemaker-lite
- changemaker-lite-freealberta
volumes:
- mysql_data:/var/lib/mysql
- mysql_data_test4:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "${GITEA_DB_USER:-gitea}", "-p${GITEA_DB_PASSWD}"]
interval: 10s
@ -233,21 +233,210 @@ services:
mini-qr:
image: ghcr.io/lyqht/mini-qr:latest
container_name: mini-qr
container_name: mini-qr-freealberta
ports:
- "${MINI_QR_PORT:-8089}:8080"
restart: unless-stopped
networks:
- changemaker-lite
- changemaker-lite-freealberta
# Shared Redis - Used by all services for caching, queues, sessions
redis:
image: redis:7-alpine
container_name: redis-changemaker-freealberta
command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru
ports:
- "${REDIS_PORT:-6379}:6379"
volumes:
- redis-data-freealberta:/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-freealberta
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-freealberta
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-freealberta:/prometheus
restart: always
networks:
- changemaker-lite-freealberta
profiles:
- monitoring
# Grafana - Metrics visualization for all services
grafana:
image: grafana/grafana:latest
container_name: grafana-changemaker-freealberta
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-freealberta:/var/lib/grafana
- ./configs/grafana:/etc/grafana/provisioning
restart: always
depends_on:
- prometheus
networks:
- changemaker-lite-freealberta
profiles:
- monitoring
# cAdvisor - Container metrics exporter for Docker
cadvisor:
image: gcr.io/cadvisor/cadvisor:latest
container_name: cadvisor-changemaker-freealberta
ports:
- "${CADVISOR_PORT:-8080}:8080"
volumes:
- /:/rootfs:ro
- /var/run:/var/run:ro
- /sys:/sys:ro
- /var/lib/docker/:/var/lib/docker:ro
- /dev/disk/:/dev/disk:ro
privileged: true
devices:
- /dev/kmsg
restart: always
networks:
- changemaker-lite-freealberta
profiles:
- monitoring
# Node Exporter - System metrics exporter
node-exporter:
image: prom/node-exporter:latest
container_name: node-exporter-changemaker-freealberta
ports:
- "${NODE_EXPORTER_PORT:-9100}:9100"
command:
- '--path.rootfs=/host'
- '--path.procfs=/host/proc'
- '--path.sysfs=/host/sys'
- '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/rootfs:ro
restart: always
networks:
- changemaker-lite-freealberta
profiles:
- monitoring
# Redis Exporter - Redis metrics exporter
redis-exporter:
image: oliver006/redis_exporter:latest
container_name: redis-exporter-changemaker-freealberta
ports:
- "${REDIS_EXPORTER_PORT:-9121}:9121"
environment:
- REDIS_ADDR=redis:6379
restart: always
depends_on:
- redis
networks:
- changemaker-lite-freealberta
profiles:
- monitoring
# Alertmanager - Alert routing and notification
alertmanager:
image: prom/alertmanager:latest
container_name: alertmanager-changemaker-freealberta
ports:
- "${ALERTMANAGER_PORT:-9093}:9093"
volumes:
- ./configs/alertmanager:/etc/alertmanager
- alertmanager-data:/alertmanager
command:
- '--config.file=/etc/alertmanager/alertmanager.yml'
- '--storage.path=/alertmanager'
restart: always
networks:
- changemaker-lite-freealberta
profiles:
- monitoring
# Gotify - Self-hosted push notification service
gotify:
image: gotify/server:latest
container_name: gotify-changemaker-freealberta
ports:
- "${GOTIFY_PORT:-8889}:80"
environment:
- GOTIFY_DEFAULTUSER_NAME=${GOTIFY_ADMIN_USER:-admin}
- GOTIFY_DEFAULTUSER_PASS=${GOTIFY_ADMIN_PASSWORD:-admin}
- TZ=Etc/UTC
volumes:
- gotify-data:/app/data
restart: always
networks:
- changemaker-lite-freealberta
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-freealberta
ports:
- "${MAILHOG_SMTP_PORT:-1025}:1025" # SMTP server
- "${MAILHOG_WEB_PORT:-8025}:8025" # Web UI
restart: unless-stopped
networks:
- changemaker-lite-freealberta
logging:
driver: "json-file"
options:
max-size: "5m"
max-file: "2"
networks:
changemaker-lite:
changemaker-lite-freealberta:
driver: bridge
volumes:
listmonk-data:
n8n_data:
nc_data:
db_data:
gitea_data:
mysql_data:
listmonk-data-freealberta:
n8n_data_test4:
nc_data_test4:
db_data_test4:
gitea_data_test4:
mysql_data_test4:
redis-data-freealberta:
prometheus-data-freealberta:
grafana-data-freealberta:
alertmanager-data:
gotify-data:

View File

@ -0,0 +1,442 @@
x-db-credentials: &db-credentials
POSTGRES_USER: &db-user ${POSTGRES_USER}
POSTGRES_PASSWORD: &db-password ${POSTGRES_PASSWORD}
POSTGRES_DB: &db-name ${POSTGRES_DB}
services:
code-server:
build:
context: .
dockerfile: Dockerfile.code-server
container_name: code-server-changemaker
environment:
- DOCKER_USER=${USER_NAME:-coder}
- DEFAULT_WORKSPACE=/home/coder/mkdocs/
user: "${USER_ID:-1000}:${GROUP_ID:-1000}"
volumes:
- ./configs/code-server/.config:/home/coder/.config
- ./configs/code-server/.local:/home/coder/.local
- ./mkdocs:/home/coder/mkdocs/
ports:
- "${CODE_SERVER_PORT:-8888}:8080"
restart: unless-stopped
networks:
- changemaker-lite
listmonk-app:
image: listmonk/listmonk:latest
container_name: listmonk_app_test4
restart: unless-stopped
ports:
- "${LISTMONK_PORT:-9001}:9000"
networks:
- changemaker-lite
hostname: ${LISTMONK_HOSTNAME}
depends_on:
- listmonk-db
command: [sh, -c, "./listmonk --install --idempotent --yes --config '' && ./listmonk --upgrade --yes --config '' && ./listmonk --config ''"]
environment:
LISTMONK_app__address: 0.0.0.0:9000
LISTMONK_db__user: *db-user
LISTMONK_db__password: *db-password
LISTMONK_db__database: *db-name
LISTMONK_db__host: listmonk-db
LISTMONK_db__port: ${LISTMONK_DB_PORT:-5432}
LISTMONK_db__ssl_mode: disable
LISTMONK_db__max_open: ${LISTMONK_DB_MAX_OPEN:-25}
LISTMONK_db__max_idle: ${LISTMONK_DB_MAX_IDLE:-25}
LISTMONK_db__max_lifetime: ${LISTMONK_DB_MAX_LIFETIME:-300s}
TZ: Etc/UTC
LISTMONK_ADMIN_USER: ${LISTMONK_ADMIN_USER:-}
LISTMONK_ADMIN_PASSWORD: ${LISTMONK_ADMIN_PASSWORD:-}
volumes:
- ./assets/uploads:/listmonk/uploads:rw
listmonk-db:
image: postgres:17-alpine
container_name: listmonk_db_test4
restart: unless-stopped
ports:
- "127.0.0.1:${LISTMONK_DB_PORT:-5432}:5432"
networks:
- changemaker-lite
environment:
<<: *db-credentials
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
interval: 10s
timeout: 5s
retries: 6
volumes:
- type: volume
source: listmonk-data
target: /var/lib/postgresql/data
mkdocs:
image: squidfunk/mkdocs-material
container_name: mkdocs-changemaker
volumes:
- ./mkdocs:/docs:rw
- ./assets/images:/docs/assets/images:rw
user: "${USER_ID:-1000}:${GROUP_ID:-1000}"
ports:
- "${MKDOCS_PORT:-4000}:8000"
environment:
- SITE_URL=${BASE_DOMAIN:-https://changeme.org}
command: serve --dev-addr=0.0.0.0:8000 --watch-theme --livereload
networks:
- changemaker-lite
restart: unless-stopped
mkdocs-site-server:
image: lscr.io/linuxserver/nginx:latest
container_name: mkdocs-site-server-changemaker
environment:
- PUID=${USER_ID:-1000}
- PGID=${GROUP_ID:-1000}
- TZ=Etc/UTC
volumes:
- ./mkdocs/site:/config/www
- ./configs/mkdocs-site/default.conf:/config/nginx/site-confs/default.conf # Add this line
ports:
- "${MKDOCS_SITE_SERVER_PORT:-4001}:80"
restart: unless-stopped
networks:
- changemaker-lite
n8n:
image: docker.n8n.io/n8nio/n8n
container_name: n8n-changemaker
restart: unless-stopped
ports:
- "${N8N_PORT:-5678}:5678"
environment:
- N8N_HOST=${N8N_HOST:-n8n.${DOMAIN}}
- N8N_PORT=5678
- N8N_PROTOCOL=https
- NODE_ENV=production
- WEBHOOK_URL=https://${N8N_HOST:-n8n.${DOMAIN}}/
- GENERIC_TIMEZONE=${GENERIC_TIMEZONE:-UTC}
- N8N_ENCRYPTION_KEY=${N8N_ENCRYPTION_KEY:-changeMe}
- N8N_USER_MANAGEMENT_DISABLED=false
- N8N_DEFAULT_USER_EMAIL=${N8N_USER_EMAIL:-admin@example.com}
- N8N_DEFAULT_USER_PASSWORD=${N8N_USER_PASSWORD:-changeMe}
volumes:
- n8n_data_test4:/home/node/.n8n
- ./local-files:/files
networks:
- changemaker-lite
nocodb:
depends_on:
root_db:
condition: service_healthy
environment:
NC_DB: "pg://root_db:5432?u=postgres&p=password&d=root_db"
image: "nocodb/nocodb:latest"
ports:
- "${NOCODB_PORT:-8090}:8080"
restart: always
volumes:
- "nc_data_test4:/usr/app/data"
networks:
- changemaker-lite
root_db:
environment:
POSTGRES_DB: root_db
POSTGRES_PASSWORD: password
POSTGRES_USER: postgres
healthcheck:
interval: 10s
retries: 10
test: "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""
timeout: 2s
image: postgres:16.6
restart: always
volumes:
- "db_data_test4:/var/lib/postgresql/data"
networks:
- changemaker-lite
# Homepage App
homepage-changemaker:
image: ghcr.io/gethomepage/homepage:latest
container_name: homepage-changemaker
ports:
- "${HOMEPAGE_PORT:-3010}:3000"
volumes:
- ./configs/homepage:/app/config
- ./assets/icons:/app/public/icons
- ./assets/images:/app/public/images
- /var/run/docker.sock:/var/run/docker.sock
environment:
- PUID=${USER_ID:-1000}
- PGID=${DOCKER_GROUP_ID:-984}
- TZ=Etc/UTC
- HOMEPAGE_ALLOWED_HOSTS=*
- HOMEPAGE_VAR_BASE_URL=${HOMEPAGE_VAR_BASE_URL:-http://localhost}
restart: unless-stopped
networks:
- changemaker-lite
# Gitea - Git service
gitea-app:
image: gitea/gitea:1.23.7
container_name: gitea_changemaker_test4
environment:
- USER_UID=${USER_ID:-1000}
- USER_GID=${GROUP_ID:-1000}
- GITEA__database__DB_TYPE=${GITEA_DB_TYPE:-mysql}
- GITEA__database__HOST=${GITEA_DB_HOST:-gitea-db:3306}
- GITEA__database__NAME=${GITEA_DB_NAME:-gitea}
- GITEA__database__USER=${GITEA_DB_USER:-gitea}
- GITEA__database__PASSWD=${GITEA_DB_PASSWD}
- GITEA__server__ROOT_URL=${GITEA_ROOT_URL}
- GITEA__server__HTTP_PORT=3000
- GITEA__server__PROTOCOL=http
- GITEA__server__DOMAIN=${GITEA_DOMAIN}
- GITEA__server__ENABLE_GZIP=true
- GITEA__server__PROXY_PROTOCOL=true
- GITEA__server__PROXY_PROXY_PROTOCOL_TLS=true
- GITEA__server__PROXY_ALLOW_SUBNET=0.0.0.0/0
restart: unless-stopped
networks:
- changemaker-lite
volumes:
- gitea_data_test4:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "${GITEA_WEB_PORT:-3030}:3000"
- "${GITEA_SSH_PORT:-2222}:22"
depends_on:
- gitea-db
gitea-db:
image: mysql:8
container_name: gitea_mysql_changemaker_test4_test4
restart: unless-stopped
environment:
- MYSQL_ROOT_PASSWORD=${GITEA_DB_ROOT_PASSWORD}
- MYSQL_USER=${GITEA_DB_USER:-gitea}
- MYSQL_PASSWORD=${GITEA_DB_PASSWD}
- MYSQL_DATABASE=${GITEA_DB_NAME:-gitea}
networks:
- changemaker-lite
volumes:
- mysql_data_test4:/var/lib/mysql
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "${GITEA_DB_USER:-gitea}", "-p${GITEA_DB_PASSWD}"]
interval: 10s
timeout: 5s
retries: 5
mini-qr:
image: ghcr.io/lyqht/mini-qr:latest
container_name: mini-qr
ports:
- "${MINI_QR_PORT:-8089}:8080"
restart: unless-stopped
networks:
- 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
# cAdvisor - Container metrics exporter for Docker
cadvisor:
image: gcr.io/cadvisor/cadvisor:latest
container_name: cadvisor-changemaker
ports:
- "${CADVISOR_PORT:-8080}:8080"
volumes:
- /:/rootfs:ro
- /var/run:/var/run:ro
- /sys:/sys:ro
- /var/lib/docker/:/var/lib/docker:ro
- /dev/disk/:/dev/disk:ro
privileged: true
devices:
- /dev/kmsg
restart: always
networks:
- changemaker-lite
profiles:
- monitoring
# Node Exporter - System metrics exporter
node-exporter:
image: prom/node-exporter:latest
container_name: node-exporter-changemaker
ports:
- "${NODE_EXPORTER_PORT:-9100}:9100"
command:
- '--path.rootfs=/host'
- '--path.procfs=/host/proc'
- '--path.sysfs=/host/sys'
- '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/rootfs:ro
restart: always
networks:
- changemaker-lite
profiles:
- monitoring
# Redis Exporter - Redis metrics exporter
redis-exporter:
image: oliver006/redis_exporter:latest
container_name: redis-exporter-changemaker
ports:
- "${REDIS_EXPORTER_PORT:-9121}:9121"
environment:
- REDIS_ADDR=redis:6379
restart: always
depends_on:
- redis
networks:
- changemaker-lite
profiles:
- monitoring
# Alertmanager - Alert routing and notification
alertmanager:
image: prom/alertmanager:latest
container_name: alertmanager-changemaker
ports:
- "${ALERTMANAGER_PORT:-9093}:9093"
volumes:
- ./configs/alertmanager:/etc/alertmanager
- alertmanager-data:/alertmanager
command:
- '--config.file=/etc/alertmanager/alertmanager.yml'
- '--storage.path=/alertmanager'
restart: always
networks:
- changemaker-lite
profiles:
- monitoring
# Gotify - Self-hosted push notification service
gotify:
image: gotify/server:latest
container_name: gotify-changemaker
ports:
- "${GOTIFY_PORT:-8889}:80"
environment:
- GOTIFY_DEFAULTUSER_NAME=${GOTIFY_ADMIN_USER:-admin}
- GOTIFY_DEFAULTUSER_PASS=${GOTIFY_ADMIN_PASSWORD:-admin}
- TZ=Etc/UTC
volumes:
- gotify-data:/app/data
restart: always
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:
changemaker-lite:
driver: bridge
volumes:
listmonk-data:
n8n_data_test4:
nc_data_test4:
db_data_test4:
gitea_data_test4:
mysql_data_test4:
redis-data:
prometheus-data:
grafana-data:
alertmanager-data:
gotify-data:

118
fix-permissions.sh Executable file
View File

@ -0,0 +1,118 @@
#!/bin/bash
# Fix Container Directory Permissions
# Run this script if you encounter EACCES (permission denied) errors
# when starting Docker containers.
cat << "EOF"
╔═══════════════════════════════════════════════════╗
║ Changemaker - Fix Container Permissions ║
╚═══════════════════════════════════════════════════╝
EOF
# Get the absolute path of the script directory
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
# Get the user/group IDs from .env or use defaults
if [ -f "$SCRIPT_DIR/.env" ]; then
source <(grep -E '^(USER_ID|GROUP_ID)=' "$SCRIPT_DIR/.env")
fi
USER_ID=${USER_ID:-1000}
GROUP_ID=${GROUP_ID:-1000}
echo ""
echo "Using UID: $USER_ID, GID: $GROUP_ID"
echo ""
# Define directories that need to be writable by containers
declare -A writable_dirs=(
["$SCRIPT_DIR/configs/code-server/.config"]="Code Server config"
["$SCRIPT_DIR/configs/code-server/.local"]="Code Server local data"
["$SCRIPT_DIR/mkdocs/.cache"]="MkDocs cache (social cards, etc.)"
["$SCRIPT_DIR/mkdocs/site"]="MkDocs built site"
["$SCRIPT_DIR/assets/uploads"]="Listmonk uploads"
["$SCRIPT_DIR/assets/images"]="Shared images"
["$SCRIPT_DIR/configs/homepage/logs"]="Homepage logs"
)
errors=0
fixed=0
needs_sudo=()
echo "Checking and fixing directory permissions..."
echo ""
for dir_path in "${!writable_dirs[@]}"; do
dir_desc="${writable_dirs[$dir_path]}"
# Create directory if it doesn't exist
if [ ! -d "$dir_path" ]; then
echo " Creating: $dir_path"
mkdir -p "$dir_path"
fi
# Add .gitkeep to track empty directories in git
if [ ! -f "$dir_path/.gitkeep" ]; then
touch "$dir_path/.gitkeep" 2>/dev/null
fi
# Try to fix permissions
if chmod -R 777 "$dir_path" 2>/dev/null; then
echo "$dir_desc"
((fixed++))
else
echo " ⚠️ $dir_desc - needs sudo"
needs_sudo+=("$dir_path")
((errors++))
fi
done
echo ""
# If there are directories that need sudo, offer to fix them
if [ ${#needs_sudo[@]} -gt 0 ]; then
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Some directories need elevated permissions to fix."
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
read -p "Would you like to fix them with sudo? [Y/n]: " use_sudo
if [[ ! "$use_sudo" =~ ^[Nn]$ ]]; then
echo ""
for dir_path in "${needs_sudo[@]}"; do
dir_desc="${writable_dirs[$dir_path]}"
echo " Fixing: $dir_desc"
# First try to change ownership, then permissions
if sudo chown -R "$USER_ID:$GROUP_ID" "$dir_path" 2>/dev/null && \
sudo chmod -R 777 "$dir_path" 2>/dev/null; then
echo " ✅ Fixed: $dir_desc"
((fixed++))
((errors--))
else
echo " ❌ Failed: $dir_desc"
fi
done
fi
fi
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Summary"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " Fixed: $fixed directories"
if [ $errors -gt 0 ]; then
echo " Errors: $errors directories still need attention"
echo ""
echo "To manually fix remaining issues, run:"
echo " sudo chown -R $USER_ID:$GROUP_ID $SCRIPT_DIR"
echo " sudo chmod -R 755 $SCRIPT_DIR"
exit 1
else
echo ""
echo "✅ All container directories are properly configured!"
echo ""
echo "You can now start your containers with:"
echo " docker compose up -d"
fi

View File

@ -0,0 +1,161 @@
# CSRF Security Update - Fix Summary
## Date: October 23, 2025
## Issues Encountered
After implementing CSRF security updates, the application experienced two main issues:
### 1. Login Failed with "Invalid CSRF token"
**Problem**: The login endpoint required a CSRF token, but users couldn't get a token before logging in (chicken-and-egg problem).
**Root Cause**: The `/api/auth/login` endpoint was being protected by CSRF middleware, but there's no session yet during initial login.
**Solution**: Added `/api/auth/login` and `/api/auth/session` to the CSRF exempt routes list in `app/middleware/csrf.js`. Login endpoints use credentials (username/password) for authentication, so they don't need CSRF protection.
### 2. Campaign Creation Failed with Infinite Retry Loop
**Problem**: When creating campaigns, the app would get stuck in an infinite retry loop with repeated "CSRF token validation failed" errors.
**Root Causes**:
1. The API client (`api-client.js`) wasn't fetching or sending CSRF tokens at all
2. The retry logic didn't have a guard against infinite recursion
3. FormData wasn't including the CSRF token
**Solutions**:
1. **Added CSRF token management** to the API client:
- `fetchCsrfToken()` - Fetches token from `/api/csrf-token` endpoint
- `ensureCsrfToken()` - Ensures a valid token exists before requests
- Tokens are automatically included in state-changing requests (POST, PUT, PATCH, DELETE)
2. **Fixed infinite retry loop**:
- Added `isRetry` parameter to `makeRequest()`, `postFormData()`, and `putFormData()`
- Retry only happens once per request
- If second attempt fails, error is thrown to the user
3. **Enhanced token handling**:
- JSON requests: Token sent via `X-CSRF-Token` header
- FormData requests: Token sent via `_csrf` field
- Token automatically refreshed if server responds with new token
4. **Server-side updates**:
- Added explicit CSRF protection to `/api/csrf-token` endpoint so it can generate tokens
- Exported `csrfProtection` middleware for explicit use
## Files Modified
### 1. `app/middleware/csrf.js`
```javascript
// Added to exempt routes:
const csrfExemptRoutes = [
'/api/health',
'/api/metrics',
'/api/config',
'/api/auth/login', // ← NEW: Login uses credentials
'/api/auth/session', // ← NEW: Session check is read-only
'/api/representatives/postal/',
'/api/campaigns/public'
];
// Enhanced getCsrfToken with error handling
```
### 2. `app/server.js`
```javascript
// Added csrfProtection to imports
const { conditionalCsrfProtection, getCsrfToken, csrfProtection } = require('./middleware/csrf');
// Applied explicit CSRF protection to token endpoint
app.get('/api/csrf-token', csrfProtection, getCsrfToken);
```
### 3. `app/public/js/api-client.js`
- Added CSRF token caching and fetching logic
- Modified `makeRequest()` to include `X-CSRF-Token` header
- Modified `postFormData()` and `putFormData()` to include `_csrf` field
- Added retry logic with infinite loop protection (max 1 retry)
- Added automatic token refresh on 403 errors
## How CSRF Protection Works Now
### Flow for State-Changing Requests (POST, PUT, DELETE):
```
1. User Action (e.g., "Create Campaign")
2. API Client checks if CSRF token exists
↓ (if no token)
3. Fetch token from GET /api/csrf-token
4. Include token in request:
- Header: X-CSRF-Token (for JSON)
- FormData: _csrf (for file uploads)
5. Server validates token matches session
6a. Success → Process request
6b. Invalid Token → Return 403
↓ (on 403, if not a retry)
7. Clear token, fetch new one, retry ONCE
8a. Success → Return data
8b. Still fails → Throw error to user
```
### Protected vs Exempt Endpoints
**Protected (requires CSRF token)**:
- ✅ POST `/api/admin/campaigns` - Create campaign
- ✅ PUT `/api/admin/campaigns/:id` - Update campaign
- ✅ POST `/api/emails/send` - Send email
- ✅ POST `/api/auth/logout` - Logout
- ✅ POST `/api/auth/change-password` - Change password
**Exempt (no CSRF required)**:
- ✅ GET (all GET requests are safe)
- ✅ POST `/api/auth/login` - Uses credentials
- ✅ GET `/api/auth/session` - Read-only check
- ✅ GET `/api/health` - Public health check
- ✅ GET `/api/metrics` - Prometheus metrics
## Testing Checklist
- [x] Login as admin works
- [ ] Create new campaign works
- [ ] Update existing campaign works
- [ ] Delete campaign works
- [ ] Send email to representative works
- [ ] Logout works
- [ ] Password change works
- [ ] Public pages work without authentication
## Security Benefits
1. **CSRF Attack Prevention**: Malicious sites can't forge requests to your app
2. **Session Hijacking Protection**: httpOnly, secure, sameSite cookies
3. **Defense in Depth**: Multiple security layers (Helmet, rate limiting, CSRF, validation)
4. **Automatic Token Rotation**: Tokens refresh on each response when available
5. **Retry Logic**: Handles token expiration gracefully
## Important Notes
- CSRF tokens are tied to sessions and expire with the session (1 hour)
- Tokens are stored in cookies (httpOnly, secure in production)
- The retry logic prevents infinite loops by limiting to 1 retry per request
- Login doesn't need CSRF because it uses credentials for authentication
- All state-changing operations (POST/PUT/DELETE) now require valid CSRF tokens
## Troubleshooting
**If you see "Invalid CSRF token" errors:**
1. Check browser console for detailed error messages
2. Clear browser cookies and session storage
3. Logout and login again to get a fresh session
4. Verify the session hasn't expired (1 hour timeout)
5. Check server logs for CSRF validation failures
**If infinite retry loop occurs:**
1. Check that `isRetry` parameter is being passed correctly
2. Verify FormData isn't being reused across retries
3. Clear the API client's cached token: `window.apiClient.csrfToken = null`

View File

@ -0,0 +1,243 @@
# Custom Recipients Implementation
## Overview
This feature allows campaigns to target any email address (custom recipients) instead of or in addition to elected representatives from the Represent API.
## Implementation Summary
### ✅ Backend (Complete)
#### 1. Database Schema (`scripts/build-nocodb.sh`)
- **custom_recipients table** with fields:
- `id` - Primary key
- `campaign_id` - Links to campaigns table
- `campaign_slug` - Campaign identifier
- `recipient_name` - Full name of recipient
- `recipient_email` - Email address
- `recipient_title` - Job title/position (optional)
- `recipient_organization` - Organization name (optional)
- `notes` - Internal notes (optional)
- `is_active` - Boolean flag
- **campaigns table** updated:
- Added `allow_custom_recipients` boolean field (default: false)
#### 2. Backend Controller (`app/controllers/customRecipients.js`)
Full CRUD operations:
- `getRecipientsByCampaign(req, res)` - Fetch all recipients for a campaign
- `createRecipient(req, res)` - Add single recipient with validation
- `bulkCreateRecipients(req, res)` - Import multiple recipients from CSV
- `updateRecipient(req, res)` - Update recipient details
- `deleteRecipient(req, res)` - Delete single recipient
- `deleteAllRecipients(req, res)` - Clear all recipients for a campaign
#### 3. NocoDB Service (`app/services/nocodb.js`)
- `getCustomRecipients(campaignId)` - Query by campaign ID
- `getCustomRecipientsBySlug(campaignSlug)` - Query by slug
- `createCustomRecipient(recipientData)` - Create with field mapping
- `updateCustomRecipient(recipientId, updateData)` - Partial updates
- `deleteCustomRecipient(recipientId)` - Single deletion
- `deleteCustomRecipientsByCampaign(campaignId)` - Bulk deletion
#### 4. API Routes (`app/routes/api.js`)
All routes protected with `requireNonTemp` authentication:
- `GET /api/campaigns/:slug/custom-recipients` - List all recipients
- `POST /api/campaigns/:slug/custom-recipients` - Create single recipient
- `POST /api/campaigns/:slug/custom-recipients/bulk` - Bulk import
- `PUT /api/campaigns/:slug/custom-recipients/:id` - Update recipient
- `DELETE /api/campaigns/:slug/custom-recipients/:id` - Delete recipient
- `DELETE /api/campaigns/:slug/custom-recipients` - Delete all recipients
#### 5. Campaign Controller Updates (`app/controllers/campaigns.js`)
- Added `allow_custom_recipients` field to all campaign CRUD operations
- Field normalization in 5+ locations for consistent API responses
### ✅ Frontend (Complete)
#### 1. JavaScript Module (`app/public/js/custom-recipients.js`)
Comprehensive module with:
- **CRUD Operations**: Add, edit, delete recipients
- **Bulk Import**: CSV file upload or paste with parsing
- **Validation**: Email format validation
- **UI Management**: Dynamic recipient list display with cards
- **Error Handling**: User-friendly error messages
- **XSS Protection**: HTML escaping for security
Key methods:
```javascript
CustomRecipients.init(campaignSlug) // Initialize module
CustomRecipients.loadRecipients(slug) // Load from API
CustomRecipients.displayRecipients() // Render list
// Plus handleAddRecipient, handleEditRecipient, handleDeleteRecipient, etc.
```
#### 2. Admin Panel Integration (`app/public/admin.html` + `app/public/js/admin.js`)
- **Create Form**: Checkbox to enable custom recipients
- **Edit Form**:
- Checkbox with show/hide toggle
- Add recipient form (5 fields: name, email, title, organization, notes)
- Bulk CSV import button with modal
- Recipients list with edit/delete actions
- Clear all button
- **JavaScript Integration**:
- `toggleCustomRecipientsSection()` - Show/hide based on checkbox
- `setupCustomRecipientsHandlers()` - Event listeners for checkbox
- Auto-load recipients when editing campaign with feature enabled
- Form data includes `allow_custom_recipients` in create/update
#### 3. Bulk Import Modal (`app/public/admin.html`)
Complete modal with:
- CSV format instructions
- File upload input
- Paste textarea for direct CSV input
- Import results display with success/failure details
- CSV format: `recipient_name,recipient_email,recipient_title,recipient_organization,notes`
#### 4. CSS Styling (`app/public/admin.html`)
- `.recipient-card` - Card layout with hover effects
- `.recipient-info` - Name, email, metadata display
- `.recipient-actions` - Edit/delete icon buttons with hover colors
- `.bulk-import-help` - Modal styling
- Responsive grid layout
## Usage
### For Administrators:
1. **Create Campaign**:
- Check "Allow Custom Recipients" during creation
- An info section will appear explaining that recipients can be added after campaign is created
- Complete the campaign creation
2. **Edit Campaign**:
- Navigate to the Edit tab and select your campaign
- Check "Allow Custom Recipients" to enable the feature
- The custom recipients management section will appear below the checkbox
3. **Add Single Recipient**:
- Fill in name (required) and email (required)
- Optionally add title, organization, notes
- Click "Add Recipient"
4. **Bulk Import**:
- Click "Bulk Import (CSV)" button
- Upload CSV file or paste CSV data
- CSV format: `recipient_name,recipient_email,recipient_title,recipient_organization,notes`
- First row can be header (will be skipped if contains "recipient_name")
- Results show success/failure for each row
5. **Edit Recipient**:
- Click edit icon on recipient card
- Form populates with current data
- Make changes and click "Update Recipient"
- Or click "Cancel" to revert
6. **Delete Recipients**:
- Single: Click delete icon on card
- All: Click "Clear All" button
### API Examples:
```bash
# Create recipient
curl -X POST /api/campaigns/my-campaign/custom-recipients \
-H "Content-Type: application/json" \
-d '{
"recipient_name": "Jane Doe",
"recipient_email": "jane@example.com",
"recipient_title": "CEO",
"recipient_organization": "Tech Corp"
}'
# Bulk import
curl -X POST /api/campaigns/my-campaign/custom-recipients/bulk \
-H "Content-Type: application/json" \
-d '{
"recipients": [
{"recipient_name": "John Smith", "recipient_email": "john@example.com"},
{"recipient_name": "Jane Doe", "recipient_email": "jane@example.com"}
]
}'
# Get all recipients
curl /api/campaigns/my-campaign/custom-recipients
# Update recipient
curl -X PUT /api/campaigns/my-campaign/custom-recipients/123 \
-H "Content-Type: application/json" \
-d '{"recipient_title": "CTO"}'
# Delete recipient
curl -X DELETE /api/campaigns/my-campaign/custom-recipients/123
# Delete all recipients
curl -X DELETE /api/campaigns/my-campaign/custom-recipients
```
## Security Features
- **Authentication**: All API routes require non-temporary user session
- **Validation**: Email format validation on client and server
- **XSS Protection**: HTML escaping in display
- **Campaign Check**: Verifies campaign exists and feature is enabled
- **Input Sanitization**: express-validator on API endpoints
## Next Steps (TODO)
1. **Dashboard Integration**: Add same UI to `dashboard.html` for regular users
2. **Campaign Display**: Update `campaign.js` to show custom recipients alongside elected officials
3. **Email Composer**: Ensure custom recipients work in email sending flow
4. **Testing**: Comprehensive end-to-end testing
5. **Documentation**: Update main README and files-explainer
## Files Modified/Created
### Backend:
- ✅ `scripts/build-nocodb.sh` - Database schema
- ✅ `app/controllers/customRecipients.js` - NEW FILE (282 lines)
- ✅ `app/services/nocodb.js` - Service methods
- ✅ `app/routes/api.js` - API endpoints
- ✅ `app/controllers/campaigns.js` - Field updates
### Frontend:
- ✅ `app/public/js/custom-recipients.js` - NEW FILE (538 lines)
- ✅ `app/public/js/admin.js` - Integration code
- ✅ `app/public/admin.html` - UI components and forms
### Documentation:
- ✅ `CUSTOM_RECIPIENTS_IMPLEMENTATION.md` - This file
## Testing Checklist
- [ ] Database table creation (run build-nocodb.sh)
- [ ] Create campaign with custom recipients enabled
- [ ] Add single recipient via form
- [ ] Edit recipient information
- [ ] Delete single recipient
- [ ] Bulk import via CSV file
- [ ] Bulk import via paste
- [ ] Clear all recipients
- [ ] Toggle checkbox on/off
- [ ] Verify API authentication
- [ ] Test with campaign where feature is disabled
- [ ] Check recipient display on campaign page
- [ ] Test email sending to custom recipients
## Known Limitations
1. Custom recipients can only be added AFTER campaign is created (not during creation)
2. Dashboard UI not yet implemented (admin panel only)
3. Campaign display page doesn't show custom recipients yet
4. CSV import uses simple comma splitting (doesn't handle quoted commas)
5. No duplicate email detection
## Future Enhancements
- [ ] Duplicate email detection/prevention
- [ ] Import validation preview before saving
- [ ] Export recipients to CSV
- [ ] Recipient groups/categories
- [ ] Import from external sources (Google Contacts, etc.)
- [ ] Recipient engagement tracking
- [ ] Custom fields for recipients
- [ ] Merge tags in email templates using recipient data

View File

@ -0,0 +1,119 @@
# Debugging Custom Recipients Feature
## Changes Made to Fix Checkbox Toggle
### Issue
The "Allow Custom Recipients" checkbox wasn't showing/hiding the custom recipients management section when clicked.
### Root Causes
1. **Event Listener Timing**: Original code tried to attach event listeners during `init()`, but the edit form elements didn't exist yet
2. **Not Following Best Practices**: Wasn't using event delegation pattern as required by `instruct.md`
### Solution
Switched to **event delegation** pattern using a single document-level listener:
```javascript
// OLD (didn't work - elements didn't exist yet):
const editCheckbox = document.getElementById('edit-allow-custom-recipients');
if (editCheckbox) {
editCheckbox.addEventListener('change', handler);
}
// NEW (works with event delegation):
document.addEventListener('change', (e) => {
if (e.target.id === 'edit-allow-custom-recipients') {
// Handle the change
}
});
```
### Benefits of Event Delegation
1. ✅ Works regardless of when elements are added to DOM
2. ✅ Follows `instruct.md` rules about using `addEventListener`
3. ✅ No need to reattach listeners when switching tabs
4. ✅ Single listener handles all checkbox changes efficiently
### Console Logs Added for Debugging
The following console logs were added to help trace execution:
1. **admin.js init()**: "AdminPanel init started" and "AdminPanel init completed"
2. **custom-recipients.js load**: "Custom Recipients module loading..." and "Custom Recipients module initialized"
3. **setupCustomRecipientsHandlers()**: "Setting up custom recipients handlers" and "Custom recipients handlers set up with event delegation"
4. **Checkbox change**: "Custom recipients checkbox changed: true/false"
5. **Module init**: "Initializing CustomRecipients module for campaign: [slug]"
6. **toggleCustomRecipientsSection()**: "Toggling custom recipients section: true/false" and "Section display set to: block/none"
### Testing Steps
1. **Open Browser Console** (F12)
2. **Navigate to Admin Panel** → Look for "AdminPanel init started"
3. **Look for Module Load** → "Custom Recipients module loading..."
**Test Create Form:**
4. **Switch to Create Tab** → Click "Create New Campaign"
5. **Check the Checkbox** → "Allow Custom Recipients"
6. **Verify Info Section Appears** → Should see: "Custom recipients can only be added after the campaign is created"
7. **Console Should Show**: "Create form: Custom recipients checkbox changed: true"
**Test Edit Form:**
8. **Switch to Edit Tab** → Select a campaign
9. **Check the Checkbox** → "Allow Custom Recipients"
10. **You Should See**:
- "Custom recipients checkbox changed: true"
- "Toggling custom recipients section: true"
- "Section display set to: block"
- "Initializing CustomRecipients module for campaign: [slug]"
11. **Verify Section Appears** → The "Manage Custom Recipients" section with forms should now be visible
### If It Still Doesn't Work
Check the following in browser console:
1. **Are scripts loading?**
```
Look for: "Custom Recipients module loading..."
If missing: Check network tab for 404 errors on custom-recipients.js
```
2. **Is event delegation working?**
```
Look for: "Custom recipients handlers set up with event delegation"
If missing: Check if setupCustomRecipientsHandlers() is being called
```
3. **Is checkbox being detected?**
```
Click checkbox and look for: "Custom recipients checkbox changed: true"
If missing: Check if checkbox ID is correct in HTML
```
4. **Is section element found?**
```
Look for: "section found: [object HTMLDivElement]"
If it says "section found: null": Check if section ID matches in HTML
```
5. **Manual test in console:**
```javascript
// Check if checkbox exists
document.getElementById('edit-allow-custom-recipients')
// Check if section exists
document.getElementById('edit-custom-recipients-section')
// Check if module loaded
window.CustomRecipients
// Manually toggle section
document.getElementById('edit-custom-recipients-section').style.display = 'block';
```
### Files Modified
- ✅ `app/public/js/admin.js` - Changed to event delegation pattern, added logging
- ✅ `app/public/js/custom-recipients.js` - Added loading logs
- ✅ No changes needed to HTML (already correct)
### Next Steps After Confirming It Works
1. Remove excessive console.log statements (or convert to debug mode)
2. Test full workflow: add recipient, edit, delete, bulk import
3. Proceed with dashboard.html integration

View File

@ -221,6 +221,7 @@ RATE_LIMIT_MAX_REQUESTS=100
- **👤 Collect User Info**: Request user name and email
- **📊 Show Email Count**: Display total emails sent (engagement metric)
- **✏️ Allow Email Editing**: Let users customize email template
- **⭐ Highlight Campaign**: Feature this campaign on the homepage (replaces postal code search)
- **🎯 Target Government Levels**: Select Federal, Provincial, Municipal, School Board
7. **Set Campaign Status**:
@ -248,6 +249,37 @@ The homepage automatically displays all active campaigns in a responsive grid be
- Tablet: 2 columns
- Mobile: 1 column
- **Click Navigation**: Users can click any campaign card to visit the full campaign page
### Highlighted Campaign Feature
Promote a priority campaign by highlighting it on the homepage, replacing the postal code search section with featured campaign information.
**How to Highlight a Campaign**:
1. Navigate to Admin Panel → Edit Campaign
2. Select the campaign you want to feature
3. Check the "⭐ Highlight Campaign" checkbox
4. Save changes
**Highlighted Campaign Display**:
- **Homepage Takeover**: Replaces postal code search with campaign showcase
- **Featured Badge**: Shows "⭐ Featured Campaign" badge
- **Campaign Details**: Displays title, description, and engagement stats
- **Primary CTA**: Large "Join This Campaign" button
- **Fallback Option**: "Find Representatives by Postal Code" button for users who want standard lookup
- **Visual Indicators**: Gold border and badge in admin panel campaign list
**Important Notes**:
- **One at a Time**: Only ONE campaign can be highlighted simultaneously
- **Auto-Unset**: Setting a new highlighted campaign automatically removes highlighting from previous campaign
- **Requires Active Status**: Campaign must have status="active" to be highlighted
- **Admin Control**: Only administrators can set highlighted campaigns
**Technical Implementation**:
- Backend validates and ensures single highlighted campaign via `setHighlightedCampaign()`
- Frontend checks `/public/highlighted-campaign` API on page load
- Postal code lookup remains accessible via button click
- Highlighting state persists across page reloads
- **Smart Loading**: Shows loading state while fetching campaigns, gracefully hides section if no active campaigns exist
- **Security**: HTML content is escaped to prevent XSS attacks
- **Sorting**: Campaigns display newest first by creation date
@ -434,6 +466,36 @@ The Response Wall creates transparency and accountability by allowing campaign p
5. Admins can mark verified responses with special badge
6. Community upvotes highlight most impactful responses
### QR Code Sharing Feature
The application includes dynamic QR code generation for easy campaign and response wall sharing.
**Key Features:**
- **Campaign QR Codes**: Generate scannable QR codes for campaign pages
- **Response Wall QR Codes**: Share response walls with QR codes for mobile scanning
- **High-Quality Generation**: 400x400px PNG images with high error correction (level H)
- **Download Support**: One-click download of QR code images for printing or sharing
- **Social Integration**: QR code button alongside social share buttons (Facebook, Twitter, LinkedIn, etc.)
- **Caching**: QR codes cached for 1 hour to improve performance
**How to Use:**
1. Visit any campaign page or response wall
2. Click the QR code icon (📱) in the social share buttons section
3. A modal appears with the generated QR code
4. Scan with any smartphone camera to visit the page
5. Click "Download QR Code" to save the image for printing or sharing
**Technical Implementation:**
- Backend endpoint: `GET /api/campaigns/:slug/qrcode?type=campaign|response-wall`
- Uses `qrcode` npm package for generation
- Proper MIME type and cache headers
- Modal UI with download functionality
**Use Cases:**
- Print QR codes on flyers and posters for offline campaign promotion
- Share QR codes in presentations and meetings
- Include in email newsletters for mobile-friendly access
- Display at events for easy sign-up
### Email Integration
- Modal-based email composer
- Pre-filled recipient information

View File

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

View File

@ -1,6 +1,7 @@
const nocoDB = require('../services/nocodb');
const emailService = require('../services/email');
const representAPI = require('../services/represent-api');
const qrcodeService = require('../services/qrcode');
const { generateSlug, validateSlug } = require('../utils/validators');
const multer = require('multer');
const path = require('path');
@ -131,6 +132,13 @@ class CampaignsController {
console.log(`Call count result: ${callCount}`);
}
// Get verified response count
let verifiedResponseCount = 0;
if (id != null) {
verifiedResponseCount = await nocoDB.getCampaignVerifiedResponseCount(id);
console.log(`Verified response count result: ${verifiedResponseCount}`);
}
const rawTargetLevels = campaign['Target Government Levels'] || campaign.target_government_levels;
const normalizedTargetLevels = normalizeTargetLevels(rawTargetLevels);
@ -148,7 +156,8 @@ class CampaignsController {
target_government_levels: normalizedTargetLevels,
created_at: campaign.CreatedAt || campaign.created_at,
emailCount,
callCount
callCount,
verifiedResponseCount
};
})
);
@ -167,6 +176,75 @@ class CampaignsController {
}
}
// Get the currently highlighted campaign (public)
async getHighlightedCampaign(req, res, next) {
try {
const campaign = await nocoDB.getHighlightedCampaign();
if (!campaign) {
return res.json({
success: true,
campaign: null
});
}
const id = campaign.ID || campaign.Id || campaign.id;
// Get email count if show_email_count is enabled
let emailCount = null;
const showEmailCount = campaign['Show Email Count'] || campaign.show_email_count;
if (showEmailCount && id != null) {
emailCount = await nocoDB.getCampaignEmailCount(id);
}
// Get call count if show_call_count is enabled
let callCount = null;
const showCallCount = campaign['Show Call Count'] || campaign.show_call_count;
if (showCallCount && id != null) {
callCount = await nocoDB.getCampaignCallCount(id);
}
// Get verified response count
let verifiedResponseCount = 0;
if (id != null) {
verifiedResponseCount = await nocoDB.getCampaignVerifiedResponseCount(id);
}
const rawTargetLevels = campaign['Target Government Levels'] || campaign.target_government_levels;
const normalizedTargetLevels = normalizeTargetLevels(rawTargetLevels);
// Return only public-facing information
const highlightedCampaign = {
id,
slug: campaign['Campaign Slug'] || campaign.slug,
title: campaign['Campaign Title'] || campaign.title,
description: campaign['Description'] || campaign.description,
call_to_action: campaign['Call to Action'] || campaign.call_to_action,
cover_photo: campaign['Cover Photo'] || campaign.cover_photo,
show_email_count: showEmailCount,
show_call_count: showCallCount,
show_response_wall: campaign['Show Response Wall Button'] || campaign.show_response_wall,
target_government_levels: normalizedTargetLevels,
created_at: campaign.CreatedAt || campaign.created_at,
emailCount,
callCount,
verifiedResponseCount
};
res.json({
success: true,
campaign: highlightedCampaign
});
} catch (error) {
console.error('Get highlighted campaign error:', error);
res.status(500).json({
success: false,
error: 'Failed to retrieve highlighted campaign',
message: error.message
});
}
}
// Get all campaigns (for admin panel)
async getAllCampaigns(req, res, next) {
try {
@ -199,7 +277,9 @@ class CampaignsController {
collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info,
show_email_count: campaign['Show Email Count'] || campaign.show_email_count,
allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing,
allow_custom_recipients: campaign['Allow Custom Recipients'] || campaign.allow_custom_recipients,
show_response_wall: campaign['Show Response Wall Button'] || campaign.show_response_wall,
highlight_campaign: campaign['Highlight Campaign'] || campaign.highlight_campaign || false,
target_government_levels: normalizedTargetLevels,
created_at: campaign.CreatedAt || campaign.created_at,
updated_at: campaign.UpdatedAt || campaign.updated_at,
@ -282,6 +362,7 @@ class CampaignsController {
collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info,
show_email_count: campaign['Show Email Count'] || campaign.show_email_count,
allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing,
allow_custom_recipients: campaign['Allow Custom Recipients'] || campaign.allow_custom_recipients,
show_response_wall: campaign['Show Response Wall Button'] || campaign.show_response_wall,
target_government_levels: normalizeTargetLevels(campaign['Target Government Levels'] || campaign.target_government_levels),
created_at: campaign.CreatedAt || campaign.created_at,
@ -369,6 +450,7 @@ class CampaignsController {
show_email_count: campaign['Show Email Count'] || campaign.show_email_count,
show_call_count: campaign['Show Call Count'] || campaign.show_call_count,
allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing,
allow_custom_recipients: campaign['Allow Custom Recipients'] || campaign.allow_custom_recipients,
show_response_wall: campaign['Show Response Wall Button'] || campaign.show_response_wall,
target_government_levels: normalizeTargetLevels(campaign['Target Government Levels'] || campaign.target_government_levels),
emailCount,
@ -389,6 +471,22 @@ class CampaignsController {
// Create new campaign
async createCampaign(req, res, next) {
try {
// Convert boolean fields from string to actual boolean if needed
const booleanFields = [
'allow_smtp_email',
'allow_mailto_link',
'collect_user_info',
'show_email_count',
'allow_email_editing',
'allow_custom_recipients'
];
booleanFields.forEach(field => {
if (req.body[field] !== undefined && typeof req.body[field] === 'string') {
req.body[field] = req.body[field] === 'true';
}
});
const {
title,
description,
@ -401,6 +499,7 @@ class CampaignsController {
collect_user_info = true,
show_email_count = true,
allow_email_editing = false,
allow_custom_recipients = false,
target_government_levels = ['Federal', 'Provincial', 'Municipal']
} = req.body;
@ -431,6 +530,7 @@ class CampaignsController {
collect_user_info,
show_email_count,
allow_email_editing,
allow_custom_recipients,
// NocoDB MultiSelect expects an array of values
target_government_levels: normalizeTargetLevels(target_government_levels),
// Add user ownership data
@ -461,6 +561,7 @@ class CampaignsController {
collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info,
show_email_count: campaign['Show Email Count'] || campaign.show_email_count,
allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing,
allow_custom_recipients: campaign['Allow Custom Recipients'] || campaign.allow_custom_recipients,
target_government_levels: normalizeTargetLevels(campaign['Target Government Levels'] || campaign.target_government_levels),
created_at: campaign.CreatedAt || campaign.created_at,
updated_at: campaign.UpdatedAt || campaign.updated_at,
@ -548,6 +649,39 @@ class CampaignsController {
console.log('No cover photo file in request');
}
// Convert boolean fields from string to actual boolean
const booleanFields = [
'allow_smtp_email',
'allow_mailto_link',
'collect_user_info',
'show_email_count',
'allow_email_editing',
'show_response_wall',
'allow_custom_recipients',
'highlight_campaign'
];
booleanFields.forEach(field => {
if (updates[field] !== undefined) {
// Convert string 'true'/'false' to boolean
if (typeof updates[field] === 'string') {
updates[field] = updates[field] === 'true';
}
// Already boolean, leave as is
}
});
// Handle highlight_campaign special logic
// If this campaign is being set to highlighted, unset all others
if (updates.highlight_campaign === true) {
await nocoDB.setHighlightedCampaign(id);
// Remove from updates since we already handled it
delete updates.highlight_campaign;
} else if (updates.highlight_campaign === false) {
// Just unset this one
// Keep in updates to let the normal update flow handle it
}
console.log('Updates object before saving:', updates);
if (updates.status !== undefined) {
@ -603,6 +737,7 @@ class CampaignsController {
collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info,
show_email_count: campaign['Show Email Count'] || campaign.show_email_count,
allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing,
allow_custom_recipients: campaign['Allow Custom Recipients'] || campaign.allow_custom_recipients,
target_government_levels: normalizeTargetLevels(campaign['Target Government Levels'] || campaign.target_government_levels),
created_at: campaign.CreatedAt || campaign.created_at,
updated_at: campaign.UpdatedAt || campaign.updated_at,
@ -935,9 +1070,33 @@ class CampaignsController {
});
});
// Add custom recipients if enabled for this campaign
let customRecipients = [];
if (campaign['Allow Custom Recipients']) {
try {
customRecipients = await nocoDB.getCustomRecipientsBySlug(slug);
// Mark custom recipients with a type field to distinguish them
customRecipients = customRecipients.map(recipient => ({
...recipient,
is_custom_recipient: true,
name: recipient.recipient_name,
email: recipient.recipient_email,
elected_office: recipient.recipient_title || 'Custom Recipient',
party_name: recipient.recipient_organization || '',
photo_url: null // Custom recipients don't have photos
}));
} catch (error) {
console.error('Error loading custom recipients:', error);
// Don't fail the entire request if custom recipients fail to load
}
}
// Combine elected officials and custom recipients
const allRecipients = [...filteredRepresentatives, ...customRecipients];
res.json({
success: true,
representatives: filteredRepresentatives,
representatives: allRecipients,
location: {
city: result?.city || cachedData[0]?.city || 'Alberta',
province: result?.province || cachedData[0]?.province || 'AB'
@ -1074,6 +1233,66 @@ class CampaignsController {
});
}
}
// Generate QR code for campaign or response wall
async generateQRCode(req, res, next) {
try {
const { slug } = req.params;
const { type } = req.query; // 'campaign' or 'response-wall'
// Validate type parameter
if (type && !['campaign', 'response-wall'].includes(type)) {
return res.status(400).json({
success: false,
error: 'Invalid type parameter. Must be "campaign" or "response-wall"'
});
}
// Get campaign to verify it exists
const campaign = await nocoDB.getCampaignBySlug(slug);
if (!campaign) {
return res.status(404).json({
success: false,
error: 'Campaign not found'
});
}
// Build URL based on type
const appUrl = process.env.APP_URL || `${req.protocol}://${req.get('host')}`;
let targetUrl;
if (type === 'response-wall') {
targetUrl = `${appUrl}/response-wall.html?campaign=${slug}`;
} else {
// Default to campaign page
targetUrl = `${appUrl}/campaign/${slug}`;
}
// Generate QR code
const qrCodeBuffer = await qrcodeService.generateQRCode(targetUrl, {
width: 400,
margin: 2,
errorCorrectionLevel: 'H' // High error correction for better scanning
});
// Set response headers
res.set({
'Content-Type': 'image/png',
'Content-Length': qrCodeBuffer.length,
'Cache-Control': 'public, max-age=3600' // Cache for 1 hour
});
// Send the buffer
res.send(qrCodeBuffer);
} catch (error) {
console.error('Generate QR code error:', error);
res.status(500).json({
success: false,
error: 'Failed to generate QR code',
message: error.message
});
}
}
}
// Export controller instance and upload middleware

View File

@ -0,0 +1,283 @@
const nocoDB = require('../services/nocodb');
const { validateEmail } = require('../utils/validators');
class CustomRecipientsController {
/**
* Get all custom recipients for a campaign
*/
async getRecipientsByCampaign(req, res, next) {
try {
const { slug } = req.params;
// Get campaign first to verify it exists and get ID
const campaign = await nocoDB.getCampaignBySlug(slug);
if (!campaign) {
return res.status(404).json({ error: 'Campaign not found' });
}
// Check if custom recipients are enabled for this campaign
// Use NocoDB column title, not camelCase
if (!campaign['Allow Custom Recipients']) {
return res.json({ recipients: [], message: 'Custom recipients not enabled for this campaign' });
}
// Get custom recipients for this campaign using slug
const recipients = await nocoDB.getCustomRecipientsBySlug(slug);
res.json({
success: true,
recipients: recipients || [],
count: recipients ? recipients.length : 0
});
} catch (error) {
console.error('Error fetching custom recipients:', error);
next(error);
}
}
/**
* Create a single custom recipient
*/
async createRecipient(req, res, next) {
try {
const { slug } = req.params;
const { recipient_name, recipient_email, recipient_title, recipient_organization, notes } = req.body;
// Validate required fields
if (!recipient_name || !recipient_email) {
return res.status(400).json({ error: 'Recipient name and email are required' });
}
// Validate email format
if (!validateEmail(recipient_email)) {
return res.status(400).json({ error: 'Invalid email format' });
}
// Get campaign to verify it exists and get ID
const campaign = await nocoDB.getCampaignBySlug(slug);
if (!campaign) {
return res.status(404).json({ error: 'Campaign not found' });
}
// Check if custom recipients are enabled for this campaign
// Use NocoDB column title, not camelCase field name
if (!campaign['Allow Custom Recipients']) {
console.warn('Custom recipients not enabled. Campaign data:', campaign);
return res.status(403).json({ error: 'Custom recipients not enabled for this campaign' });
}
// Create the recipient
// Use campaign.ID (NocoDB system field) not campaign.id
const recipientData = {
campaign_id: campaign.ID,
campaign_slug: slug,
recipient_name,
recipient_email,
recipient_title: recipient_title || null,
recipient_organization: recipient_organization || null,
notes: notes || null,
is_active: true
};
const newRecipient = await nocoDB.createCustomRecipient(recipientData);
res.status(201).json({
success: true,
recipient: newRecipient,
message: 'Recipient created successfully'
});
} catch (error) {
console.error('Error creating custom recipient:', error);
next(error);
}
}
/**
* Bulk create custom recipients
*/
async bulkCreateRecipients(req, res, next) {
try {
const { slug } = req.params;
const { recipients } = req.body;
// Validate input
if (!Array.isArray(recipients) || recipients.length === 0) {
return res.status(400).json({ error: 'Recipients array is required and must not be empty' });
}
// Get campaign to verify it exists and get ID
const campaign = await nocoDB.getCampaignBySlug(slug);
if (!campaign) {
return res.status(404).json({ error: 'Campaign not found' });
}
// Check if custom recipients are enabled for this campaign
if (!campaign.allow_custom_recipients) {
return res.status(403).json({ error: 'Custom recipients not enabled for this campaign' });
}
const results = {
success: [],
failed: [],
total: recipients.length
};
// Process each recipient
for (const recipient of recipients) {
try {
// Validate required fields
if (!recipient.recipient_name || !recipient.recipient_email) {
results.failed.push({
recipient,
error: 'Missing required fields (name or email)'
});
continue;
}
// Validate email format
if (!validateEmail(recipient.recipient_email)) {
results.failed.push({
recipient,
error: 'Invalid email format'
});
continue;
}
// Create the recipient
const recipientData = {
campaign_id: campaign.id,
campaign_slug: slug,
recipient_name: recipient.recipient_name,
recipient_email: recipient.recipient_email,
recipient_title: recipient.recipient_title || null,
recipient_organization: recipient.recipient_organization || null,
notes: recipient.notes || null,
is_active: true
};
const newRecipient = await nocoDB.createCustomRecipient(recipientData);
results.success.push(newRecipient);
} catch (error) {
results.failed.push({
recipient,
error: error.message || 'Unknown error'
});
}
}
res.status(201).json({
success: true,
results,
message: `Successfully created ${results.success.length} of ${results.total} recipients`
});
} catch (error) {
console.error('Error bulk creating custom recipients:', error);
next(error);
}
}
/**
* Update a custom recipient
*/
async updateRecipient(req, res, next) {
try {
const { slug, id } = req.params;
const { recipient_name, recipient_email, recipient_title, recipient_organization, notes, is_active } = req.body;
// Validate email if provided
if (recipient_email && !validateEmail(recipient_email)) {
return res.status(400).json({ error: 'Invalid email format' });
}
// Get campaign to verify it exists
const campaign = await nocoDB.getCampaignBySlug(slug);
if (!campaign) {
return res.status(404).json({ error: 'Campaign not found' });
}
// Build update data (only include provided fields)
const updateData = {};
if (recipient_name !== undefined) updateData.recipient_name = recipient_name;
if (recipient_email !== undefined) updateData.recipient_email = recipient_email;
if (recipient_title !== undefined) updateData.recipient_title = recipient_title;
if (recipient_organization !== undefined) updateData.recipient_organization = recipient_organization;
if (notes !== undefined) updateData.notes = notes;
if (is_active !== undefined) updateData.is_active = is_active;
// Update the recipient
const updatedRecipient = await nocoDB.updateCustomRecipient(id, updateData);
if (!updatedRecipient) {
return res.status(404).json({ error: 'Recipient not found' });
}
res.json({
success: true,
recipient: updatedRecipient,
message: 'Recipient updated successfully'
});
} catch (error) {
console.error('Error updating custom recipient:', error);
next(error);
}
}
/**
* Delete a custom recipient
*/
async deleteRecipient(req, res, next) {
try {
const { slug, id } = req.params;
// Get campaign to verify it exists
const campaign = await nocoDB.getCampaignBySlug(slug);
if (!campaign) {
return res.status(404).json({ error: 'Campaign not found' });
}
// Delete the recipient
const deleted = await nocoDB.deleteCustomRecipient(id);
if (!deleted) {
return res.status(404).json({ error: 'Recipient not found' });
}
res.json({
success: true,
message: 'Recipient deleted successfully'
});
} catch (error) {
console.error('Error deleting custom recipient:', error);
next(error);
}
}
/**
* Delete all custom recipients for a campaign
*/
async deleteAllRecipients(req, res, next) {
try {
const { slug } = req.params;
// Get campaign to verify it exists and get ID
const campaign = await nocoDB.getCampaignBySlug(slug);
if (!campaign) {
return res.status(404).json({ error: 'Campaign not found' });
}
// Delete all recipients for this campaign
const deletedCount = await nocoDB.deleteCustomRecipientsByCampaign(campaign.id);
res.json({
success: true,
deletedCount,
message: `Successfully deleted ${deletedCount} recipient(s)`
});
} catch (error) {
console.error('Error deleting all custom recipients:', error);
next(error);
}
}
}
module.exports = new CustomRecipientsController();

View File

@ -0,0 +1,262 @@
const listmonkService = require('../services/listmonk');
const nocodbService = require('../services/nocodb');
const logger = require('../utils/logger');
// Get Listmonk sync status
exports.getSyncStatus = async (req, res) => {
try {
const status = listmonkService.getSyncStatus();
// Also check connection if it's enabled
if (status.enabled && !status.connected) {
// Try to reconnect
const reconnected = await listmonkService.checkConnection();
status.connected = reconnected;
}
res.json(status);
} catch (error) {
logger.error('Failed to get Listmonk status', error);
res.status(500).json({
success: false,
error: 'Failed to get sync status'
});
}
};
// Sync all campaign participants to Listmonk
exports.syncCampaignParticipants = async (req, res) => {
try {
if (!listmonkService.syncEnabled) {
return res.status(400).json({
success: false,
error: 'Listmonk sync is disabled'
});
}
// Get all campaign emails (use campaignEmails table, not emails)
const emailsData = await nocodbService.getAll(nocodbService.tableIds.campaignEmails);
const emails = emailsData?.list || [];
// Get all campaigns for reference
const campaigns = await nocodbService.getAllCampaigns();
if (!emails || emails.length === 0) {
return res.json({
success: true,
message: 'No campaign participants to sync',
results: { total: 0, success: 0, failed: 0, errors: [] }
});
}
const results = await listmonkService.bulkSyncCampaignParticipants(emails, campaigns);
res.json({
success: true,
message: `Campaign participants sync completed: ${results.success} succeeded, ${results.failed} failed`,
results
});
} catch (error) {
logger.error('Campaign participants sync failed', error);
res.status(500).json({
success: false,
error: 'Failed to sync campaign participants to Listmonk'
});
}
};
// Sync all custom recipients to Listmonk
exports.syncCustomRecipients = async (req, res) => {
try {
if (!listmonkService.syncEnabled) {
return res.status(400).json({
success: false,
error: 'Listmonk sync is disabled'
});
}
// Get all custom recipients
const recipientsData = await nocodbService.getAll(nocodbService.tableIds.customRecipients);
const recipients = recipientsData?.list || [];
// Get all campaigns for reference
const campaigns = await nocodbService.getAllCampaigns();
if (!recipients || recipients.length === 0) {
return res.json({
success: true,
message: 'No custom recipients to sync',
results: { total: 0, success: 0, failed: 0, errors: [] }
});
}
const results = await listmonkService.bulkSyncCustomRecipients(recipients, campaigns);
res.json({
success: true,
message: `Custom recipients sync completed: ${results.success} succeeded, ${results.failed} failed`,
results
});
} catch (error) {
logger.error('Custom recipients sync failed', error);
res.status(500).json({
success: false,
error: 'Failed to sync custom recipients to Listmonk'
});
}
};
// Sync everything (participants and custom recipients)
exports.syncAll = async (req, res) => {
try {
if (!listmonkService.syncEnabled) {
return res.status(400).json({
success: false,
error: 'Listmonk sync is disabled'
});
}
let results = {
participants: { total: 0, success: 0, failed: 0, errors: [] },
customRecipients: { total: 0, success: 0, failed: 0, errors: [] }
};
// Get campaigns once for both syncs
const campaigns = await nocodbService.getAllCampaigns();
// Sync campaign participants
try {
const emailsData = await nocodbService.getAll(nocodbService.tableIds.campaignEmails);
const emails = emailsData?.list || [];
if (emails && emails.length > 0) {
results.participants = await listmonkService.bulkSyncCampaignParticipants(emails, campaigns);
}
} catch (error) {
logger.error('Failed to sync campaign participants during full sync', error);
results.participants.errors.push({ error: error.message });
}
// Sync custom recipients
try {
const recipientsData = await nocodbService.getAll(nocodbService.tableIds.customRecipients);
const recipients = recipientsData?.list || [];
if (recipients && recipients.length > 0) {
results.customRecipients = await listmonkService.bulkSyncCustomRecipients(recipients, campaigns);
}
} catch (error) {
logger.error('Failed to sync custom recipients during full sync', error);
results.customRecipients.errors.push({ error: error.message });
}
const totalSuccess = results.participants.success + results.customRecipients.success;
const totalFailed = results.participants.failed + results.customRecipients.failed;
res.json({
success: true,
message: `Complete sync finished: ${totalSuccess} succeeded, ${totalFailed} failed`,
results
});
} catch (error) {
logger.error('Complete sync failed', error);
res.status(500).json({
success: false,
error: 'Failed to perform complete sync'
});
}
};
// Get Listmonk list statistics
exports.getListStats = async (req, res) => {
try {
if (!listmonkService.syncEnabled) {
return res.json({
success: false,
error: 'Listmonk sync is disabled',
stats: null
});
}
const stats = await listmonkService.getListStats();
// Convert stats object to array format for frontend
let statsArray = [];
if (stats && typeof stats === 'object') {
statsArray = Object.entries(stats).map(([key, list]) => ({
id: key,
name: list.name,
subscriberCount: list.subscriber_count || 0,
description: `Email list for ${key}`
}));
}
res.json({
success: true,
stats: statsArray
});
} catch (error) {
logger.error('Failed to get Listmonk list stats', error);
res.status(500).json({
success: false,
error: 'Failed to get list statistics'
});
}
};
// Test Listmonk connection
exports.testConnection = async (req, res) => {
try {
const connected = await listmonkService.checkConnection();
if (connected) {
res.json({
success: true,
message: 'Listmonk connection successful',
connected: true
});
} else {
res.json({
success: false,
message: listmonkService.lastError || 'Connection failed',
connected: false
});
}
} catch (error) {
logger.error('Failed to test Listmonk connection', error);
res.status(500).json({
success: false,
error: 'Failed to test connection'
});
}
};
// Reinitialize Listmonk lists
exports.reinitializeLists = async (req, res) => {
try {
if (!listmonkService.syncEnabled) {
return res.status(400).json({
success: false,
error: 'Listmonk sync is disabled'
});
}
const initialized = await listmonkService.initializeLists();
if (initialized) {
res.json({
success: true,
message: 'Listmonk lists reinitialized successfully'
});
} else {
res.json({
success: false,
message: listmonkService.lastError || 'Failed to initialize lists'
});
}
} catch (error) {
logger.error('Failed to reinitialize Listmonk lists', error);
res.status(500).json({
success: false,
error: 'Failed to reinitialize lists'
});
}
};

View File

@ -229,6 +229,54 @@ class RepresentativesController {
});
}
}
async geocodeAddress(req, res, next) {
try {
const { address } = req.body;
const axios = require('axios');
console.log(`Geocoding address: ${address}`);
// Use Nominatim API (OpenStreetMap)
const encodedAddress = encodeURIComponent(address);
const url = `https://nominatim.openstreetmap.org/search?format=json&q=${encodedAddress}&limit=1&countrycodes=ca`;
const response = await axios.get(url, {
headers: {
'User-Agent': 'BNKops-Influence-Tool/1.0'
},
timeout: 5000
});
if (response.data && response.data.length > 0 && response.data[0].lat && response.data[0].lon) {
const result = {
lat: parseFloat(response.data[0].lat),
lng: parseFloat(response.data[0].lon),
display_name: response.data[0].display_name
};
console.log(`Geocoded "${address}" to:`, result);
res.json({
success: true,
data: result
});
} else {
console.log(`No geocoding results for: ${address}`);
res.json({
success: false,
message: 'No results found for this address'
});
}
} catch (error) {
console.error('Geocoding error:', error);
res.status(500).json({
success: false,
error: 'Geocoding failed',
message: error.message
});
}
}
}
module.exports = new RepresentativesController();

View File

@ -1,4 +1,6 @@
const nocodbService = require('../services/nocodb');
const emailService = require('../services/email');
const crypto = require('crypto');
const { validateResponse } = require('../utils/validators');
/**
@ -118,6 +120,48 @@ async function submitResponse(req, res) {
screenshotUrl = `/uploads/responses/${req.file.filename}`;
}
// DEBUG: Log verification-related fields
console.log('=== VERIFICATION DEBUG ===');
console.log('send_verification from form:', responseData.send_verification);
console.log('representative_email from form:', responseData.representative_email);
console.log('representative_name from form:', responseData.representative_name);
// Generate verification token if verification is requested and email is provided
let verificationToken = null;
let verificationSentAt = null;
// Handle send_verification - could be string, boolean, or array from form
let sendVerificationValue = responseData.send_verification;
if (Array.isArray(sendVerificationValue)) {
// If it's an array, check if any value indicates true
sendVerificationValue = sendVerificationValue.some(val => val === 'true' || val === true || val === 'on');
}
const sendVerification = sendVerificationValue === 'true' || sendVerificationValue === true || sendVerificationValue === 'on';
console.log('sendVerification evaluated to:', sendVerification);
// 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;
console.log('representativeEmail after processing:', representativeEmail);
if (sendVerification && representativeEmail) {
// Generate a secure random token
verificationToken = crypto.randomBytes(32).toString('hex');
verificationSentAt = new Date().toISOString();
console.log('Generated verification token:', verificationToken.substring(0, 16) + '...');
console.log('Verification sent at:', verificationSentAt);
} else {
console.log('Skipping verification token generation. sendVerification:', sendVerification, 'representativeEmail:', representativeEmail);
}
// Normalize is_anonymous checkbox value
const isAnonymous = responseData.is_anonymous === true ||
responseData.is_anonymous === 'true' ||
responseData.is_anonymous === 'on';
// Prepare response data for NocoDB
const newResponse = {
campaign_id: campaign.ID || campaign.Id || campaign.id || campaign['Campaign ID'],
@ -132,9 +176,14 @@ async function submitResponse(req, res) {
submitted_by_name: responseData.submitted_by_name || null,
submitted_by_email: responseData.submitted_by_email || null,
submitted_by_user_id: req.user?.id || null,
is_anonymous: responseData.is_anonymous || false,
is_anonymous: isAnonymous,
status: 'pending', // All submissions start as pending
is_verified: false,
representative_email: representativeEmail,
verification_token: verificationToken,
verification_sent_at: verificationSentAt,
verified_at: null,
verified_by: null,
upvote_count: 0,
submitted_ip: req.ip || req.connection.remoteAddress
};
@ -144,10 +193,50 @@ async function submitResponse(req, res) {
// Create response in database
const createdResponse = await nocodbService.createRepresentativeResponse(newResponse);
// Send verification email if requested
let verificationEmailSent = false;
if (sendVerification && representativeEmail && verificationToken) {
try {
const baseUrl = process.env.BASE_URL || `${req.protocol}://${req.get('host')}`;
const verificationUrl = `${baseUrl}/api/responses/${createdResponse.id}/verify/${verificationToken}`;
const reportUrl = `${baseUrl}/api/responses/${createdResponse.id}/report/${verificationToken}`;
const campaignTitle = campaign.Title || campaign.title || 'Unknown Campaign';
const submittedDate = new Date().toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
await emailService.sendResponseVerification({
representativeEmail,
representativeName: responseData.representative_name,
campaignTitle,
responseType: responseData.response_type,
responseText: responseData.response_text,
submittedDate,
submitterName: responseData.is_anonymous ? 'Anonymous' : (responseData.submitted_by_name || 'A constituent'),
verificationUrl,
reportUrl
});
verificationEmailSent = true;
console.log('Verification email sent successfully to:', representativeEmail);
} catch (emailError) {
console.error('Failed to send verification email:', emailError);
// Don't fail the whole request if email fails
}
}
const responseMessage = verificationEmailSent
? 'Response submitted successfully. A verification email has been sent to the representative. Your response will be visible after moderation.'
: 'Response submitted successfully. It will be visible after moderation.';
res.status(201).json({
success: true,
message: 'Response submitted successfully. It will be visible after moderation.',
response: createdResponse
message: responseMessage,
response: createdResponse,
verificationEmailSent
});
} catch (error) {
@ -309,6 +398,12 @@ async function getResponseStats(req, res) {
res.json({
success: true,
campaign: {
title: campaign['Campaign Title'] || campaign.title,
description: campaign['Description'] || campaign.description,
slug: campaign['Campaign Slug'] || campaign.slug,
cover_photo: campaign['Cover Photo'] || campaign.cover_photo
},
stats: {
totalResponses,
verifiedResponses,
@ -534,6 +629,407 @@ async function deleteResponse(req, res) {
}
}
/**
* Verify a response using verification token
* Public endpoint - no authentication required
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
async function verifyResponse(req, res) {
try {
const { id, token } = req.params;
console.log('=== VERIFICATION ATTEMPT ===');
console.log('Response ID:', id);
console.log('Token from URL:', token);
// Get the response
const response = await nocodbService.getRepresentativeResponseById(id);
if (!response) {
console.log('Response not found for ID:', id);
return res.status(404).send(`
<!DOCTYPE html>
<html>
<head>
<title>Response Not Found</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
h1 { color: #e74c3c; }
</style>
</head>
<body>
<h1> Response Not Found</h1>
<p>The response you're trying to verify could not be found.</p>
<p>It may have been deleted or the link may be incorrect.</p>
</body>
</html>
`);
}
console.log('Response found:', {
id: response.id,
verification_token: response.verification_token,
verification_token_type: typeof response.verification_token,
token_from_url: token,
token_from_url_type: typeof token,
tokens_match: response.verification_token === token
});
// Check if token matches
if (response.verification_token !== token) {
console.log('Token mismatch! Expected:', response.verification_token, 'Got:', token);
return res.status(403).send(`
<!DOCTYPE html>
<html>
<head>
<title>Invalid Verification Token</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
h1 { color: #e74c3c; }
</style>
</head>
<body>
<h1> Invalid Verification Token</h1>
<p>The verification link is invalid or has expired.</p>
</body>
</html>
`);
}
// Check if already verified
if (response.verified_at) {
return res.send(`
<!DOCTYPE html>
<html>
<head>
<title>Already Verified</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
h1 { color: #3498db; }
</style>
</head>
<body>
<h1> Already Verified</h1>
<p>This response has already been verified on ${new Date(response.verified_at).toLocaleDateString()}.</p>
</body>
</html>
`);
}
// Update response to verified
const updatedData = {
is_verified: true,
verified_at: new Date().toISOString(),
verified_by: response.representative_email || 'Representative',
status: 'approved' // Auto-approve when verified by representative
};
await nocodbService.updateRepresentativeResponse(id, updatedData);
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>Response Verified</title>
<style>
body {
font-family: Arial, sans-serif;
text-align: center;
padding: 50px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.container {
background: white;
color: #333;
padding: 40px;
border-radius: 10px;
max-width: 600px;
margin: 0 auto;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
h1 { color: #27ae60; margin-top: 0; }
.checkmark { font-size: 60px; }
a { color: #3498db; text-decoration: none; }
a:hover { text-decoration: underline; }
</style>
</head>
<body>
<div class="container">
<div class="checkmark"></div>
<h1>Response Verified!</h1>
<p>Thank you for verifying this response.</p>
<p>The response has been marked as verified and will now appear with a verification badge on the Response Wall.</p>
<p style="margin-top: 30px; font-size: 14px; color: #7f8c8d;">
You can close this window now.
</p>
</div>
</body>
</html>
`);
} catch (error) {
console.error('Error verifying response:', error);
res.status(500).send(`
<!DOCTYPE html>
<html>
<head>
<title>Verification Error</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
h1 { color: #e74c3c; }
</style>
</head>
<body>
<h1> Verification Error</h1>
<p>An error occurred while verifying the response.</p>
<p>Please try again later or contact support.</p>
</body>
</html>
`);
}
}
/**
* Report a response as invalid using verification token
* Public endpoint - no authentication required
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
async function reportResponse(req, res) {
try {
const { id, token } = req.params;
// Get the response
const response = await nocodbService.getRepresentativeResponseById(id);
if (!response) {
return res.status(404).send(`
<!DOCTYPE html>
<html>
<head>
<title>Response Not Found</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
h1 { color: #e74c3c; }
</style>
</head>
<body>
<h1> Response Not Found</h1>
<p>The response you're trying to report could not be found.</p>
</body>
</html>
`);
}
// Check if token matches
if (response.verification_token !== token) {
return res.status(403).send(`
<!DOCTYPE html>
<html>
<head>
<title>Invalid Token</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
h1 { color: #e74c3c; }
</style>
</head>
<body>
<h1> Invalid Token</h1>
<p>The report link is invalid or has expired.</p>
</body>
</html>
`);
}
// Update response status to rejected (disputed by representative)
const updatedData = {
status: 'rejected',
is_verified: false,
verified_at: null,
verified_by: `Disputed by ${response.representative_email || 'Representative'}`
};
await nocodbService.updateRepresentativeResponse(id, updatedData);
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>Response Reported</title>
<style>
body {
font-family: Arial, sans-serif;
text-align: center;
padding: 50px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.container {
background: white;
color: #333;
padding: 40px;
border-radius: 10px;
max-width: 600px;
margin: 0 auto;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
}
h1 { color: #e74c3c; margin-top: 0; }
.icon { font-size: 60px; }
</style>
</head>
<body>
<div class="container">
<div class="icon"></div>
<h1>Response Reported</h1>
<p>Thank you for reporting this response.</p>
<p>The response has been marked as disputed and will be hidden from public view while we investigate.</p>
<p style="margin-top: 30px; font-size: 14px; color: #7f8c8d;">
You can close this window now.
</p>
</div>
</body>
</html>
`);
} catch (error) {
console.error('Error reporting response:', error);
res.status(500).send(`
<!DOCTYPE html>
<html>
<head>
<title>Report Error</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
h1 { color: #e74c3c; }
</style>
</head>
<body>
<h1> Report Error</h1>
<p>An error occurred while reporting the response.</p>
<p>Please try again later or contact support.</p>
</body>
</html>
`);
}
}
/**
* Resend verification email for a response
* Public endpoint - no authentication required
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
async function resendVerification(req, res) {
try {
const { id } = req.params;
console.log('=== RESEND VERIFICATION REQUEST ===');
console.log('Response ID:', id);
// Get the response
const response = await nocodbService.getRepresentativeResponseById(id);
if (!response) {
console.log('Response not found for ID:', id);
return res.status(404).json({
success: false,
error: 'Response not found'
});
}
// Check if already verified
if (response.verified_at) {
return res.status(400).json({
success: false,
error: 'This response has already been verified'
});
}
// Check if we have the necessary data
if (!response.representative_email) {
return res.status(400).json({
success: false,
error: 'No representative email on file for this response'
});
}
if (!response.verification_token) {
// Generate a new token if one doesn't exist
const crypto = require('crypto');
const newToken = crypto.randomBytes(32).toString('hex');
const verificationSentAt = new Date().toISOString();
await nocodbService.updateRepresentativeResponse(id, {
verification_token: newToken,
verification_sent_at: verificationSentAt
});
response.verification_token = newToken;
response.verification_sent_at = verificationSentAt;
}
// Get campaign details
const campaign = await nocodbService.getCampaignBySlug(response.campaign_slug);
if (!campaign) {
return res.status(404).json({
success: false,
error: 'Campaign not found'
});
}
// Send verification email
try {
const baseUrl = process.env.BASE_URL || `${req.protocol}://${req.get('host')}`;
const verificationUrl = `${baseUrl}/api/responses/${response.id}/verify/${response.verification_token}`;
const reportUrl = `${baseUrl}/api/responses/${response.id}/report/${response.verification_token}`;
const campaignTitle = campaign.Title || campaign.title || 'Unknown Campaign';
const submittedDate = new Date(response.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
await emailService.sendResponseVerification({
representativeEmail: response.representative_email,
representativeName: response.representative_name,
campaignTitle,
responseType: response.response_type,
responseText: response.response_text,
submittedDate,
submitterName: response.is_anonymous ? 'Anonymous' : (response.submitted_by_name || 'A constituent'),
verificationUrl,
reportUrl
});
// Update verification_sent_at timestamp
await nocodbService.updateRepresentativeResponse(id, {
verification_sent_at: new Date().toISOString()
});
console.log('Verification email resent successfully to:', response.representative_email);
res.json({
success: true,
message: 'Verification email sent successfully to the representative'
});
} catch (emailError) {
console.error('Failed to send verification email:', emailError);
res.status(500).json({
success: false,
error: 'Failed to send verification email. Please try again later.'
});
}
} catch (error) {
console.error('Error resending verification:', error);
res.status(500).json({
success: false,
error: 'An error occurred while processing your request'
});
}
}
module.exports = {
getCampaignResponses,
submitResponse,
@ -543,5 +1039,8 @@ module.exports = {
getAdminResponses,
updateResponseStatus,
updateResponse,
deleteResponse
deleteResponse,
verifyResponse,
reportResponse,
resendVerification
};

View File

@ -0,0 +1,142 @@
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/logout', // Logout is an authentication action
'/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();
}
// Log CSRF validation attempt for debugging
console.log('=== CSRF VALIDATION ===');
console.log('Method:', req.method);
console.log('Path:', req.path);
console.log('Body Token:', req.body?._csrf ? 'YES' : 'NO');
console.log('Header Token:', req.headers['x-csrf-token'] ? 'YES' : 'NO');
console.log('CSRF Cookie:', req.cookies['_csrf'] ? 'YES' : 'NO');
console.log('Session ID:', req.session?.id || 'NO_SESSION');
console.log('=======================');
// Apply CSRF protection for state-changing operations
csrfProtection(req, res, (err) => {
if (err) {
console.log('=== CSRF ERROR ===');
console.log('Error Message:', err.message);
console.log('Error Code:', err.code);
console.log('Path:', req.path);
console.log('==================');
logger.warn('CSRF token validation failed');
csrfErrorHandler(err, req, res, next);
} else {
logger.info('CSRF validation passed for:', req.path);
next();
}
});
};
/**
* Helper to get CSRF token for client-side use
*/
const getCsrfToken = (req, res) => {
try {
// Generate a CSRF token if one doesn't exist
const token = req.csrfToken();
console.log('=== CSRF TOKEN GENERATION ===');
console.log('Token Length:', token?.length || 0);
console.log('Has Token:', !!token);
console.log('Session ID:', req.session?.id || 'NO_SESSION');
console.log('Cookie will be set:', !!req.cookies);
console.log('=============================');
res.json({
csrfToken: token
});
} catch (error) {
console.log('=== CSRF TOKEN ERROR ===');
console.log('Error:', error.message);
console.log('Stack:', error.stack);
console.log('========================');
logger.error('Failed to generate CSRF token', { error: error.message, stack: error.stack });
res.status(500).json({
error: 'Failed to generate CSRF token'
});
}
};
module.exports = {
csrfProtection,
csrfErrorHandler,
injectCsrfToken,
conditionalCsrfProtection,
getCsrfToken
};

View File

@ -28,7 +28,17 @@
"nodemailer": "^6.9.4",
"express-session": "^1.17.3",
"bcryptjs": "^2.4.3",
"multer": "^1.4.5-lts.1"
"multer": "^1.4.5-lts.1",
"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": {
"nodemon": "^3.0.1",

View File

@ -106,6 +106,25 @@
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
}
.campaign-card.highlighted {
border: 2px solid #ffd700;
box-shadow: 0 0 15px rgba(255, 215, 0, 0.3);
}
.campaign-highlight-badge {
position: absolute;
top: 10px;
right: 10px;
background: #ffd700;
color: #333;
padding: 0.3rem 0.8rem;
border-radius: 20px;
font-size: 0.85rem;
font-weight: bold;
z-index: 10;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
}
.campaign-card-cover {
background-size: cover;
background-position: center;
@ -762,6 +781,97 @@
opacity: 0.8;
}
/* Custom Recipients Styles */
.recipient-card {
background: white;
border: 1px solid #ddd;
border-radius: 8px;
padding: 1rem;
margin-bottom: 0.75rem;
display: flex;
justify-content: space-between;
align-items: flex-start;
transition: box-shadow 0.2s;
}
.recipient-card:hover {
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.recipient-info {
flex: 1;
}
.recipient-info h5 {
margin: 0 0 0.25rem 0;
color: #2c3e50;
font-size: 1rem;
}
.recipient-info .recipient-email {
color: #3498db;
font-size: 0.9rem;
margin-bottom: 0.25rem;
}
.recipient-info .recipient-meta {
color: #666;
font-size: 0.85rem;
margin: 0.25rem 0;
}
.recipient-actions {
display: flex;
gap: 0.5rem;
margin-left: 1rem;
}
.btn-icon {
padding: 0.5rem;
background: none;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
transition: all 0.2s;
}
.btn-icon:hover {
background: #f8f9fa;
transform: scale(1.1);
}
.btn-icon.edit:hover {
border-color: #3498db;
color: #3498db;
}
.btn-icon.delete:hover {
border-color: #e74c3c;
color: #e74c3c;
}
/* Bulk Import Modal */
.bulk-import-help {
background: #e3f2fd;
padding: 1rem;
border-radius: 8px;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.bulk-import-help h4 {
margin: 0 0 0.5rem 0;
color: #1976d2;
}
.bulk-import-help code {
background: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-family: monospace;
}
@media (max-width: 768px) {
.admin-nav {
flex-direction: column;
@ -803,6 +913,7 @@
<button class="nav-btn" data-tab="edit">Edit Campaign</button>
<button class="nav-btn" data-tab="responses">Response Moderation</button>
<button class="nav-btn" data-tab="users">User Management</button>
<button class="nav-btn" data-tab="listmonk">📧 Email Sync</button>
</nav>
<!-- Success/Error Messages -->
@ -918,12 +1029,29 @@ Sincerely,
<input type="checkbox" id="create-allow-editing" name="allow_email_editing">
<label for="create-allow-editing">✏️ Allow Email Editing</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="create-allow-custom-recipients" name="allow_custom_recipients">
<label for="create-allow-custom-recipients">📧 Allow Custom Recipients</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="create-show-response-wall" name="show_response_wall">
<label for="create-show-response-wall">💬 Show Response Wall Button</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="create-highlight-campaign" name="highlight_campaign">
<label for="create-highlight-campaign">⭐ Highlight on Homepage (replaces postal code search)</label>
</div>
</div>
</div>
<!-- Custom Recipients Management Section (hidden by default) -->
<div id="create-custom-recipients-section" class="section-header" style="display: none;">
<div class="section-header">📧 Manage Custom Recipients</div>
<p style="color: #666; font-size: 0.9rem; margin-bottom: 1rem;">
Add specific people or organizations to target with this campaign.
<strong>Note:</strong> Custom recipients can only be added after the campaign is created.
</p>
</div>
<div class="section-header">🏛️ Target Government Levels</div>
<div class="form-group">
@ -1039,10 +1167,18 @@ Sincerely,
<input type="checkbox" id="edit-allow-editing" name="allow_email_editing">
<label for="edit-allow-editing">✏️ Allow Email Editing</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="edit-allow-custom-recipients" name="allow_custom_recipients">
<label for="edit-allow-custom-recipients">📧 Allow Custom Recipients</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="edit-show-response-wall" name="show_response_wall">
<label for="edit-show-response-wall">💬 Show Response Wall Button</label>
</div>
<div class="checkbox-item">
<input type="checkbox" id="edit-highlight-campaign" name="highlight_campaign">
<label for="edit-highlight-campaign">⭐ Highlight on Homepage (replaces postal code search)</label>
</div>
</div>
</div>
@ -1073,6 +1209,67 @@ Sincerely,
<button type="button" class="btn btn-secondary" data-action="cancel-edit">Cancel</button>
</div>
</form>
<!-- Custom Recipients Management Section (Outside the form to avoid nested forms) -->
<div id="edit-custom-recipients-section" style="display: none; margin-top: 2rem;">
<div class="section-header">📧 Manage Custom Recipients</div>
<p style="color: #666; font-size: 0.9rem; margin-bottom: 1rem;">
Add specific people or organizations to target with this campaign instead of (or in addition to) elected representatives.
</p>
<!-- Add Single Recipient Form -->
<form id="add-recipient-form" class="form-grid" style="background: #f8f9fa; padding: 1.5rem; border-radius: 8px; margin-bottom: 1rem;">
<h4 style="grid-column: 1 / -1; margin-top: 0;">Add New Recipient</h4>
<div class="form-group">
<label for="recipient-name">Recipient Name *</label>
<input type="text" id="recipient-name" name="recipient_name" placeholder="John Smith" required>
</div>
<div class="form-group">
<label for="recipient-email">Email Address *</label>
<input type="email" id="recipient-email" name="recipient_email" placeholder="john.smith@example.com" required>
</div>
<div class="form-group">
<label for="recipient-title">Title/Position</label>
<input type="text" id="recipient-title" name="recipient_title" placeholder="CEO, Director, etc.">
</div>
<div class="form-group">
<label for="recipient-organization">Organization</label>
<input type="text" id="recipient-organization" name="recipient_organization" placeholder="Company or Organization Name">
</div>
<div class="form-group" style="grid-column: 1 / -1;">
<label for="recipient-notes">Notes (optional)</label>
<textarea id="recipient-notes" name="notes" rows="2" placeholder="Internal notes about this recipient..."></textarea>
</div>
<div class="form-row" style="grid-column: 1 / -1; margin: 0;">
<button type="submit" class="btn btn-primary">
Add Recipient
</button>
<button type="button" id="bulk-import-recipients-btn" class="btn btn-secondary">
📥 Bulk Import (CSV)
</button>
</div>
</form>
<!-- Recipients List -->
<div id="recipients-list-container">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h4 style="margin: 0;">Current Recipients</h4>
<button type="button" id="clear-all-recipients-btn" class="btn btn-danger btn-small">
🗑️ Clear All
</button>
</div>
<div id="recipients-list">
<!-- Recipients will be loaded here dynamically -->
</div>
</div>
</div>
</div>
<!-- Response Moderation Tab -->
@ -1124,6 +1321,80 @@ Sincerely,
<!-- Users will be loaded here -->
</div>
</div>
<!-- Listmonk Email Sync Tab -->
<div id="listmonk-tab" class="tab-content">
<div class="form-row" style="align-items: center; margin-bottom: 2rem;">
<h2 style="margin: 0;">📧 Email List Synchronization</h2>
<button class="btn btn-secondary" id="refresh-status-btn" onclick="refreshListmonkStatus()">🔄 Refresh Status</button>
</div>
<div class="section-header">📊 Sync Status</div>
<div id="sync-status-display" style="background: #f8f9fa; padding: 1.5rem; border-radius: 8px; margin-bottom: 2rem;">
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1rem; margin-bottom: 1rem;">
<div style="background: white; padding: 1rem; border-radius: 8px; border: 2px solid #e9ecef;">
<strong style="color: #2c3e50; display: block; margin-bottom: 0.5rem;">Connection Status:</strong>
<span id="connection-status" style="font-size: 1.1rem;">⏳ Checking...</span>
</div>
<div style="background: white; padding: 1rem; border-radius: 8px; border: 2px solid #e9ecef;">
<strong style="color: #2c3e50; display: block; margin-bottom: 0.5rem;">Auto-Sync:</strong>
<span id="autosync-status" style="font-size: 1.1rem;">⏳ Checking...</span>
</div>
</div>
<div id="last-error" style="display: none; background: #f8d7da; color: #721c24; padding: 1rem; border-radius: 8px; border: 1px solid #f5c6cb;">
<!-- Error message will be displayed here -->
</div>
</div>
<div class="section-header">🚀 Sync Actions</div>
<div style="background: white; padding: 1.5rem; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.05); margin-bottom: 2rem;">
<p style="color: #666; margin-bottom: 1.5rem;">Sync campaign participants and custom recipients to Listmonk email lists for targeted email campaigns.</p>
<div class="sync-buttons" style="display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem;">
<button class="btn btn-primary" id="sync-participants-btn" onclick="syncToListmonk('participants')">
👥 Sync Campaign Participants
</button>
<button class="btn btn-primary" id="sync-recipients-btn" onclick="syncToListmonk('recipients')">
📋 Sync Custom Recipients
</button>
<button class="btn btn-primary" id="sync-all-btn" onclick="syncToListmonk('all')">
🔄 Sync All
</button>
</div>
<div id="sync-progress" style="display: none; margin-top: 1.5rem;">
<div style="background: #f8f9fa; border-radius: 8px; overflow: hidden; height: 30px; margin-bottom: 1rem;">
<div id="sync-progress-bar" style="background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); height: 100%; width: 0%; transition: width 0.3s; display: flex; align-items: center; justify-content: center; color: white; font-weight: 600; font-size: 0.85rem;">
</div>
</div>
<div id="sync-results" style="background: #e3f2fd; padding: 1rem; border-radius: 8px; border-left: 4px solid #2196f3;">
<!-- Sync results will be displayed here -->
</div>
</div>
</div>
<div class="section-header">⚙️ Advanced Options</div>
<div style="background: white; padding: 1.5rem; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.05); margin-bottom: 2rem;">
<div style="display: flex; gap: 1rem; flex-wrap: wrap;">
<button class="btn btn-secondary" id="test-connection-btn" onclick="testListmonkConnection()">
🔌 Test Connection
</button>
<button class="btn btn-secondary" id="reinitialize-lists-btn" onclick="reinitializeListmonk()" style="background: #f39c12;">
⚠️ Reinitialize Lists
</button>
</div>
<p style="color: #666; margin-top: 1rem; font-size: 0.9rem;">
<strong>Note:</strong> Reinitializing lists will recreate all email list structures. Use only if lists are corrupted or missing.
</p>
</div>
<div class="section-header">📈 Email List Statistics</div>
<div id="listmonk-stats-section" style="background: white; padding: 1.5rem; border-radius: 12px; box-shadow: 0 4px 12px rgba(0,0,0,0.05);">
<div class="stats-list">
<p style="color: #666; text-align: center;">Loading statistics...</p>
</div>
</div>
</div>
</div>
<!-- User Modal -->
@ -1199,8 +1470,54 @@ Sincerely,
</div>
</div>
<!-- Bulk Import Recipients Modal -->
<div id="bulk-import-modal" class="modal-overlay hidden">
<div class="modal-content">
<div class="modal-header">
<h3>Bulk Import Recipients</h3>
<button class="modal-close" data-action="close-bulk-import-modal">&times;</button>
</div>
<div class="bulk-import-help">
<h4>📋 CSV Format</h4>
<p>Upload or paste CSV data with the following columns:</p>
<code>recipient_name,recipient_email,recipient_title,recipient_organization,notes</code>
<p style="margin-top: 0.5rem;"><strong>Example:</strong></p>
<code style="display: block; white-space: pre; font-size: 0.85rem;">John Smith,john@example.com,CEO,Acme Corp,Important contact
Jane Doe,jane@example.com,Director,Example Inc,</code>
</div>
<form id="bulk-import-form">
<div class="form-group">
<label for="bulk-csv-file">Upload CSV File</label>
<input type="file" id="bulk-csv-file" accept=".csv,text/csv">
</div>
<div style="text-align: center; margin: 1rem 0; color: #666;">
<strong>- OR -</strong>
</div>
<div class="form-group">
<label for="bulk-csv-text">Paste CSV Data</label>
<textarea id="bulk-csv-text" rows="8" placeholder="Paste your CSV data here..."></textarea>
</div>
<div class="form-row">
<button type="submit" class="btn btn-primary">Import Recipients</button>
<button type="button" class="btn btn-secondary" data-action="close-bulk-import-modal">Cancel</button>
</div>
</form>
<div id="bulk-import-results" class="hidden" style="margin-top: 1rem;">
<!-- Results will be shown here -->
</div>
</div>
</div>
<script src="js/api-client.js"></script>
<script src="js/auth.js"></script>
<script src="js/custom-recipients.js"></script>
<script src="js/listmonk-admin.js"></script>
<script src="js/admin.js"></script>
</body>

View File

@ -130,6 +130,75 @@
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 {
max-width: 800px;
margin: 0 auto;
@ -234,6 +303,11 @@
transition: transform 0.2s, box-shadow 0.2s;
}
.rep-card.custom-recipient {
border-left: 4px solid #9b59b6;
background: linear-gradient(135deg, #ffffff 0%, #f8f5fb 100%);
}
.rep-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
@ -252,11 +326,30 @@
border-radius: 50%;
object-fit: cover;
background: #e9ecef;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.5rem;
}
.custom-badge {
display: inline-block;
margin-left: 0.5rem;
padding: 0.25rem 0.5rem;
background: linear-gradient(135deg, #9b59b6, #8e44ad);
color: white;
border-radius: 4px;
font-size: 0.75rem;
font-weight: normal;
vertical-align: middle;
}
.rep-details h4 {
margin: 0 0 0.25rem 0;
color: #2c3e50;
display: flex;
align-items: center;
gap: 0.5rem;
}
.rep-details p {
@ -463,35 +556,110 @@
<!-- Social Share Buttons in Header -->
<div class="share-buttons-header">
<button class="share-btn-small" id="share-facebook" title="Share on Facebook">
<!-- Expandable Social Menu -->
<div class="share-socials-container">
<button class="share-btn-primary" id="share-socials-toggle" title="Share on Socials">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class="share-icon">
<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"/>
</svg>
<span>Socials</span>
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class="chevron-icon">
<path d="M7 10l5 5 5-5z"/>
</svg>
</button>
<!-- Expandable Social Options -->
<div class="share-socials-menu" id="share-socials-menu">
<button class="share-btn-small" id="share-facebook" title="Facebook">
<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>
</button>
<button class="share-btn-small" id="share-twitter" title="Share on Twitter/X">
<button class="share-btn-small" id="share-twitter" title="Twitter/X">
<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 class="share-btn-small" id="share-linkedin" title="Share on LinkedIn">
<button class="share-btn-small" id="share-linkedin" title="LinkedIn">
<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="Share on WhatsApp">
<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-email" title="Share via Email">
<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>
<button class="share-btn-small" id="share-copy" title="Copy Link">
</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">
<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>
<span>Copy Link</span>
</button>
<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">
<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>
<span>QR Code</span>
</button>
</div>
</div>
@ -539,15 +707,6 @@
</form>
</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 -->
<div id="email-preview" class="email-preview preview-mode" style="display: none;">
<h3>📧 Email Preview</h3>
@ -594,6 +753,15 @@
</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 -->
<div id="success-section" style="display: none; text-align: center; padding: 2rem;">
<h2 style="color: #27ae60;">🎉 Thank you for taking action!</h2>
@ -605,11 +773,28 @@
<div id="error-message" class="error-message" style="display: none;"></div>
</div>
<!-- QR Code Modal -->
<div id="qrcode-modal" class="qrcode-modal">
<div class="qrcode-modal-content">
<span class="qrcode-close">&times;</span>
<h2>Scan QR Code to Visit Campaign</h2>
<div class="qrcode-container">
<img id="qrcode-image" src="" alt="Campaign QR Code">
</div>
<p class="qrcode-instructions">Scan this code with your phone to visit this campaign page</p>
<button class="btn btn-secondary" id="download-qrcode-btn">Download QR Code</button>
</div>
</div>
<footer style="text-align: center; margin-top: 2rem; padding: 1rem; border-top: 1px solid #e9ecef;">
<p>&copy; 2025 <a href="https://bnkops.com/" target="_blank">BNKops</a> Influence Tool. Connect with democracy.</p>
<p><small><a href="/terms.html" id="terms-link" target="_blank">Terms of Use & Privacy Notice</a> | <a href="/index.html" id="home-link">Return to Main Page</a></small></p>
<p><small><a href="/terms.html" id="terms-link" target="_blank">Terms of Use & Privacy Notice</a></small></p>
<div style="margin-top: 1rem;">
<a href="/index.html" id="home-link" class="btn btn-secondary">Return to Main Page</a>
</div>
</footer>
<script src="/js/api-client.js"></script>
<script src="/js/campaign.js"></script>
<script>
// Update footer links with APP_URL if needed for cross-origin scenarios

View File

@ -1,5 +1,315 @@
/* Response Wall Styles */
/* Campaign Header Styles */
.response-wall-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 3rem 0;
text-align: center;
margin-bottom: 2rem;
position: relative;
overflow: hidden;
}
.response-wall-header.has-cover {
background-size: cover;
background-position: center;
background-repeat: no-repeat;
min-height: 350px;
display: flex;
align-items: center;
justify-content: center;
}
.response-wall-header.has-cover::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 1;
}
.response-wall-header > * {
position: relative;
z-index: 2;
}
.response-wall-header h1 {
font-size: 2.5rem;
margin-bottom: 0.5rem;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.7);
}
.response-wall-header .campaign-subtitle {
font-size: 1.6rem;
font-weight: 500;
margin: 0.5rem 0 1rem 0;
color: rgba(255, 255, 255, 0.95);
text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.7);
font-style: italic;
}
.response-wall-header p {
font-size: 1.2rem;
margin-bottom: 1.5rem;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.7);
}
.response-wall-header-content {
max-width: 800px;
margin: 0 auto;
padding: 0 1rem;
}
.header-nav-buttons {
display: flex;
gap: 1rem;
justify-content: center;
margin-top: 1.5rem;
flex-wrap: wrap;
}
.nav-btn {
background: rgba(255, 255, 255, 0.2);
border: 2px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
backdrop-filter: blur(10px);
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.nav-btn:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
/* Social Share Buttons in Header */
.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;
}
.share-btn-small {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
width: 36px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
backdrop-filter: blur(10px);
}
.share-btn-small:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
}
.share-btn-small svg {
width: 18px;
height: 18px;
fill: white;
}
.share-btn-small.copied {
background: rgba(40, 167, 69, 0.8);
border-color: rgba(40, 167, 69, 1);
}
.stats-banner {
display: flex;
justify-content: space-around;
@ -229,6 +539,38 @@
font-weight: bold;
}
/* Verify Button */
.verify-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border: 2px solid #27ae60;
background: white;
color: #27ae60;
border-radius: 20px;
cursor: pointer;
transition: all 0.3s ease;
font-weight: 600;
font-size: 0.9rem;
}
.verify-btn:hover {
background: #27ae60;
color: white;
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(39, 174, 96, 0.2);
}
.verify-btn .verify-icon {
font-size: 1.2rem;
font-weight: bold;
}
.verify-btn .verify-text {
white-space: nowrap;
}
/* Empty State */
.empty-state {
text-align: center;
@ -303,6 +645,47 @@
color: #7f8c8d;
}
/* Postal Lookup Styles */
.postal-lookup-container {
display: flex;
gap: 0.5rem;
}
.postal-lookup-container input {
flex: 1;
}
.postal-lookup-container .btn {
white-space: nowrap;
padding: 0.75rem 1rem;
}
#rep-select {
width: 100%;
padding: 0.5rem;
border: 2px solid #3498db;
border-radius: 4px;
font-size: 0.95rem;
background: white;
cursor: pointer;
}
#rep-select option {
padding: 0.5rem;
cursor: pointer;
}
#rep-select option:hover {
background: #f0f8ff;
}
#rep-select-group {
background: #f8f9fa;
padding: 1rem;
border-radius: 4px;
border: 1px solid #e1e8ed;
}
.form-actions {
display: flex;
gap: 1rem;
@ -313,6 +696,25 @@
flex: 1;
}
/* Checkbox styling */
.form-group input[type="checkbox"] {
width: auto;
margin-right: 0.5rem;
cursor: pointer;
}
.form-group label:has(input[type="checkbox"]) {
display: flex;
align-items: center;
font-weight: normal;
cursor: pointer;
}
.form-group input[type="checkbox"]:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.loading {
text-align: center;
padding: 2rem;
@ -360,8 +762,123 @@
align-items: flex-start;
}
.response-actions {
flex-direction: column;
width: 100%;
}
.verify-btn,
.upvote-btn {
width: 100%;
justify-content: center;
}
.modal-content {
margin: 10% 5%;
padding: 1rem;
}
}
/* QR Code Modal Styles */
.qrcode-modal {
display: none;
position: fixed;
z-index: 10000;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0, 0, 0, 0.7);
animation: fadeIn 0.3s ease-in-out;
}
.qrcode-modal.show {
display: flex;
justify-content: center;
align-items: center;
}
.qrcode-modal-content {
background-color: #fefefe;
margin: auto;
padding: 2rem;
border-radius: 12px;
max-width: 500px;
width: 90%;
position: relative;
animation: slideDown 0.3s ease-in-out;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
}
.qrcode-close {
color: #aaa;
position: absolute;
right: 1.5rem;
top: 1rem;
font-size: 28px;
font-weight: bold;
cursor: pointer;
transition: color 0.2s;
}
.qrcode-close:hover,
.qrcode-close:focus {
color: #000;
}
.qrcode-modal-content h2 {
margin-top: 0;
margin-bottom: 1.5rem;
color: #2c3e50;
text-align: center;
font-size: 1.5rem;
}
.qrcode-container {
display: flex;
justify-content: center;
align-items: center;
padding: 1.5rem;
background: #f8f9fa;
border-radius: 8px;
margin-bottom: 1rem;
}
.qrcode-container img {
max-width: 100%;
height: auto;
border-radius: 4px;
}
.qrcode-instructions {
text-align: center;
color: #6c757d;
margin-bottom: 1.5rem;
font-size: 0.95rem;
}
.qrcode-modal-content .btn {
width: 100%;
justify-content: center;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideDown {
from {
transform: translateY(-50px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -15,14 +15,31 @@
</head>
<body>
<div class="container">
<header>
<h1><a href="https://bnkops.com/" target="_blank" style="color: inherit; text-decoration: underline;">BNKops</a> Influence Tool</h1>
<p>Connect with your elected representatives across all levels of government</p>
</header>
<main>
<!-- Postal Code Input Section -->
<section id="postal-input-section">
<section id="postal-input-section" class="unified-header-section">
<div class="section-background">
<div class="gradient-overlay"></div>
<div class="particles">
<span class="particle">🇨🇦</span>
<span class="particle">📧</span>
<span class="particle">📞</span>
<span class="particle">✉️</span>
<span class="particle">📱</span>
<span class="particle">🇨🇦</span>
<span class="particle">📧</span>
<span class="particle">📞</span>
<span class="particle">🇨🇦</span>
<span class="particle">📱</span>
</div>
</div>
<!-- Header Content -->
<div class="header-content">
<h1 class="fade-in"><a href="https://bnkops.com/" target="_blank" class="brand-link">BNKops</a> Influence Tool</h1>
<p class="fade-in-delay">Connect with your elected representatives across all levels of government</p>
</div>
<div class="map-header">
<h2>Find Your Representatives</h2>
</div>
@ -55,6 +72,13 @@
<p>Looking up your representatives...</p>
</div>
</div>
<!-- Highlighted Campaign Section (inside blue background) -->
<div id="highlighted-campaign-section" class="highlighted-campaign-section" style="display: none;">
<div id="highlighted-campaign-container">
<!-- Highlighted campaign will be dynamically inserted here -->
</div>
</div>
</section>
<!-- Representatives Display Section -->
@ -78,7 +102,7 @@
</div>
</section>
<!-- Email Compose Modal -->
<!-- epose Modal -->
<div id="email-modal" class="modal" style="display: none;">
<div class="modal-content">
<div class="modal-header">

View File

@ -8,6 +8,7 @@ class AdminPanel {
}
async init() {
console.log('AdminPanel init started');
// Check authentication first
if (typeof authManager !== 'undefined') {
this.authManager = authManager;
@ -23,9 +24,12 @@ class AdminPanel {
return;
}
console.log('Setting up event listeners and form interactions');
this.setupEventListeners();
this.setupFormInteractions();
this.setupCustomRecipientsHandlers();
this.loadCampaigns();
console.log('AdminPanel init completed');
}
setupUserInterface() {
@ -484,7 +488,8 @@ class AdminPanel {
}
listDiv.innerHTML = this.campaigns.map(campaign => `
<div class="campaign-card" data-campaign-id="${campaign.id}">
<div class="campaign-card ${campaign.highlight_campaign ? 'highlighted' : ''}" data-campaign-id="${campaign.id}">
${campaign.highlight_campaign ? '<div class="campaign-highlight-badge">⭐ Highlighted</div>' : ''}
${campaign.cover_photo ? `
<div class="campaign-card-cover" style="background-image: url('/uploads/${campaign.cover_photo}');">
<div class="campaign-card-cover-overlay">
@ -573,6 +578,8 @@ class AdminPanel {
campaignFormData.append('show_email_count', formData.get('show_email_count') === 'on');
campaignFormData.append('allow_email_editing', formData.get('allow_email_editing') === 'on');
campaignFormData.append('show_response_wall', formData.get('show_response_wall') === 'on');
campaignFormData.append('allow_custom_recipients', formData.get('allow_custom_recipients') === 'on');
campaignFormData.append('highlight_campaign', formData.get('highlight_campaign') === 'on');
// Handle target_government_levels array
const targetLevels = Array.from(formData.getAll('target_government_levels'));
@ -645,6 +652,29 @@ class AdminPanel {
form.querySelector('[name="show_email_count"]').checked = campaign.show_email_count;
form.querySelector('[name="allow_email_editing"]').checked = campaign.allow_email_editing;
form.querySelector('[name="show_response_wall"]').checked = campaign.show_response_wall;
form.querySelector('[name="allow_custom_recipients"]').checked = campaign.allow_custom_recipients || false;
form.querySelector('[name="highlight_campaign"]').checked = campaign.highlight_campaign || false;
// Show/hide custom recipients section based on checkbox
this.toggleCustomRecipientsSection(campaign.allow_custom_recipients);
// Load custom recipients if enabled
if (campaign.allow_custom_recipients && window.CustomRecipients) {
console.log('Campaign has custom recipients enabled, initializing module...');
console.log('Campaign slug:', campaign.slug);
// Use setTimeout to ensure the section is visible before loading
setTimeout(() => {
console.log('Calling CustomRecipients.init() and loadRecipients()');
window.CustomRecipients.init(campaign.slug);
window.CustomRecipients.loadRecipients(campaign.slug);
}, 200);
} else {
console.log('Custom recipients not enabled or module not loaded:', {
allow_custom_recipients: campaign.allow_custom_recipients,
moduleLoaded: !!window.CustomRecipients
});
}
// Government levels
let targetLevels = [];
@ -682,6 +712,8 @@ class AdminPanel {
updateFormData.append('show_email_count', formData.get('show_email_count') === 'on');
updateFormData.append('allow_email_editing', formData.get('allow_email_editing') === 'on');
updateFormData.append('show_response_wall', formData.get('show_response_wall') === 'on');
updateFormData.append('allow_custom_recipients', formData.get('allow_custom_recipients') === 'on');
updateFormData.append('highlight_campaign', formData.get('highlight_campaign') === 'on');
// Handle target_government_levels array
const targetLevels = Array.from(formData.getAll('target_government_levels'));
@ -1329,6 +1361,53 @@ class AdminPanel {
div.textContent = text;
return div.innerHTML;
}
toggleCustomRecipientsSection(show) {
const section = document.getElementById('edit-custom-recipients-section');
console.log('Toggling custom recipients section:', show, 'section found:', section);
if (section) {
section.style.display = show ? 'block' : 'none';
console.log('Section display set to:', section.style.display);
}
}
setupCustomRecipientsHandlers() {
console.log('Setting up custom recipients handlers');
// Use event delegation on the document for the checkbox
// This way it will work even if the checkbox is added dynamically
document.addEventListener('change', (e) => {
// Handle edit form checkbox
if (e.target.id === 'edit-allow-custom-recipients') {
console.log('Custom recipients checkbox changed:', e.target.checked);
this.toggleCustomRecipientsSection(e.target.checked);
// Initialize custom recipients module if enabled
if (e.target.checked && this.currentCampaign && window.CustomRecipients) {
console.log('Initializing CustomRecipients module for campaign:', this.currentCampaign.slug);
window.CustomRecipients.init(this.currentCampaign.slug);
window.CustomRecipients.loadRecipients(this.currentCampaign.slug);
}
}
// Handle create form checkbox
if (e.target.id === 'create-allow-custom-recipients') {
console.log('Create form: Custom recipients checkbox changed:', e.target.checked);
this.toggleCreateCustomRecipientsInfo(e.target.checked);
}
});
console.log('Custom recipients handlers set up with event delegation');
}
toggleCreateCustomRecipientsInfo(show) {
const section = document.getElementById('create-custom-recipients-section');
console.log('Toggling create custom recipients info:', show, 'section found:', section);
if (section) {
section.style.display = show ? 'block' : 'none';
console.log('Create section display set to:', section.style.display);
}
}
}
// Initialize admin panel when DOM is loaded

View File

@ -2,14 +2,66 @@
class APIClient {
constructor() {
this.baseURL = '/api';
this.csrfToken = null;
this.csrfTokenPromise = null;
}
/**
* Fetch CSRF token from the server
*/
async fetchCsrfToken() {
// If we're already fetching, return the existing promise
if (this.csrfTokenPromise) {
return this.csrfTokenPromise;
}
this.csrfTokenPromise = (async () => {
try {
console.log('Fetching CSRF token from server...');
const response = await fetch(`${this.baseURL}/csrf-token`, {
credentials: 'include' // Important: include cookies
});
const data = await response.json();
this.csrfToken = data.csrfToken;
console.log('CSRF token received:', this.csrfToken ? 'Token obtained' : 'No token');
return this.csrfToken;
} catch (error) {
console.error('Failed to fetch CSRF token:', error);
this.csrfToken = null;
throw error;
} finally {
this.csrfTokenPromise = null;
}
})();
return this.csrfTokenPromise;
}
/**
* Ensure we have a valid CSRF token
*/
async ensureCsrfToken() {
if (!this.csrfToken) {
await this.fetchCsrfToken();
}
return this.csrfToken;
}
async makeRequest(endpoint, options = {}, isRetry = false) {
// For state-changing methods, ensure we have a CSRF token
const needsCsrf = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(options.method);
if (needsCsrf) {
await this.ensureCsrfToken();
}
async makeRequest(endpoint, options = {}) {
const config = {
headers: {
'Content-Type': 'application/json',
...(needsCsrf && this.csrfToken ? { 'X-CSRF-Token': this.csrfToken } : {}),
...options.headers
},
credentials: 'include', // Important: include cookies for CSRF
...options
};
@ -17,7 +69,21 @@ class APIClient {
const response = await fetch(`${this.baseURL}${endpoint}`, config);
const data = await response.json();
// If response includes a new CSRF token, update it
if (data.csrfToken) {
this.csrfToken = data.csrfToken;
}
if (!response.ok) {
// If CSRF token is invalid and we haven't retried yet, fetch a new one and retry once
if (!isRetry && response.status === 403 && data.error && data.error.includes('CSRF')) {
console.log('CSRF token invalid, fetching new token and retrying...');
this.csrfToken = null;
await this.fetchCsrfToken();
// Retry the request once with new token
return this.makeRequest(endpoint, options, true);
}
// Create enhanced error with response data for better error handling
const error = new Error(data.error || data.message || `HTTP ${response.status}`);
error.status = response.status;
@ -65,18 +131,49 @@ class APIClient {
});
}
async postFormData(endpoint, formData) {
async postFormData(endpoint, formData, isRetry = false) {
// Ensure we have a CSRF token for POST requests
await this.ensureCsrfToken();
console.log('Sending FormData with CSRF token:', this.csrfToken ? 'Token present' : 'No token');
// Add CSRF token to form data AND headers
if (this.csrfToken) {
formData.set('_csrf', this.csrfToken);
}
// Don't set Content-Type header - browser will set it with boundary for multipart/form-data
// But DO set CSRF token header
const config = {
method: 'POST',
body: formData
headers: {
...(this.csrfToken ? { 'X-CSRF-Token': this.csrfToken } : {})
},
body: formData,
credentials: 'include' // Important: include cookies for CSRF
};
try {
const response = await fetch(`${this.baseURL}${endpoint}`, config);
const data = await response.json();
// If response includes a new CSRF token, update it
if (data.csrfToken) {
this.csrfToken = data.csrfToken;
}
if (!response.ok) {
// If CSRF token is invalid and we haven't retried yet, fetch a new one and retry once
if (!isRetry && response.status === 403 && data.error && data.error.includes('CSRF')) {
console.log('CSRF token invalid, fetching new token and retrying...');
this.csrfToken = null;
await this.fetchCsrfToken();
// Update form data with new token
formData.set('_csrf', this.csrfToken);
// Retry the request once with new token
return this.postFormData(endpoint, formData, true);
}
const error = new Error(data.error || data.message || `HTTP ${response.status}`);
error.status = response.status;
error.data = data;
@ -90,18 +187,47 @@ class APIClient {
}
}
async putFormData(endpoint, formData) {
async putFormData(endpoint, formData, isRetry = false) {
// Ensure we have a CSRF token for PUT requests
await this.ensureCsrfToken();
// Add CSRF token to form data AND headers
if (this.csrfToken) {
formData.set('_csrf', this.csrfToken);
}
// Don't set Content-Type header - browser will set it with boundary for multipart/form-data
// But DO set CSRF token header
const config = {
method: 'PUT',
body: formData
headers: {
...(this.csrfToken ? { 'X-CSRF-Token': this.csrfToken } : {})
},
body: formData,
credentials: 'include' // Important: include cookies for CSRF
};
try {
const response = await fetch(`${this.baseURL}${endpoint}`, config);
const data = await response.json();
// If response includes a new CSRF token, update it
if (data.csrfToken) {
this.csrfToken = data.csrfToken;
}
if (!response.ok) {
// If CSRF token is invalid and we haven't retried yet, fetch a new one and retry once
if (!isRetry && response.status === 403 && data.error && data.error.includes('CSRF')) {
console.log('CSRF token invalid, fetching new token and retrying...');
this.csrfToken = null;
await this.fetchCsrfToken();
// Update form data with new token
formData.set('_csrf', this.csrfToken);
// Retry the request once with new token
return this.putFormData(endpoint, formData, true);
}
const error = new Error(data.error || data.message || `HTTP ${response.status}`);
error.status = response.status;
error.data = data;

View File

@ -34,6 +34,26 @@ class CampaignPage {
// Get current URL
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
document.getElementById('share-facebook')?.addEventListener('click', () => {
const url = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`;
@ -60,6 +80,93 @@ class CampaignPage {
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
document.getElementById('share-email')?.addEventListener('click', () => {
const subject = this.campaign ? `Campaign: ${this.campaign.title}` : 'Check out this campaign';
@ -105,14 +212,74 @@ class CampaignPage {
document.body.removeChild(textArea);
}
});
// QR code share
document.getElementById('share-qrcode')?.addEventListener('click', () => {
this.openQRCodeModal();
});
}
openQRCodeModal() {
const modal = document.getElementById('qrcode-modal');
const qrcodeImage = document.getElementById('qrcode-image');
const closeBtn = modal.querySelector('.qrcode-close');
const downloadBtn = document.getElementById('download-qrcode-btn');
// Build QR code URL
const qrcodeUrl = `/api/campaigns/${this.campaignSlug}/qrcode?type=campaign`;
qrcodeImage.src = qrcodeUrl;
// Show modal
modal.classList.add('show');
// Close button handler
const closeModal = () => {
modal.classList.remove('show');
};
closeBtn.onclick = closeModal;
// Close when clicking outside the modal content
modal.onclick = (event) => {
if (event.target === modal) {
closeModal();
}
};
// Download button handler
downloadBtn.onclick = async () => {
try {
const response = await fetch(qrcodeUrl);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${this.campaignSlug}-qrcode.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('Failed to download QR code:', error);
alert('Failed to download QR code. Please try again.');
}
};
// Close on Escape key
const handleEscape = (e) => {
if (e.key === 'Escape') {
closeModal();
document.removeEventListener('keydown', handleEscape);
}
};
document.addEventListener('keydown', handleEscape);
}
async loadCampaign() {
this.showLoading('Loading campaign...');
try {
const response = await fetch(`/api/campaigns/${this.campaignSlug}`);
const data = await response.json();
const data = await window.apiClient.get(`/campaigns/${this.campaignSlug}`);
if (!data.success) {
throw new Error(data.error || 'Failed to load campaign');
@ -419,20 +586,12 @@ class CampaignPage {
async trackUserInfo() {
try {
const response = await fetch(`/api/campaigns/${this.campaignSlug}/track-user`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
const data = await window.apiClient.post(`/campaigns/${this.campaignSlug}/track-user`, {
userEmail: this.userInfo.userEmail,
userName: this.userInfo.userName,
postalCode: this.userInfo.postalCode
})
});
const data = await response.json();
if (!data.success) {
console.warn('Failed to track user info:', data.error);
// Don't throw error - this is just tracking, shouldn't block the user
@ -447,8 +606,7 @@ class CampaignPage {
this.showLoading('Finding your representatives...');
try {
const response = await fetch(`/api/campaigns/${this.campaignSlug}/representatives/${this.userInfo.postalCode}`);
const data = await response.json();
const data = await window.apiClient.get(`/campaigns/${this.campaignSlug}/representatives/${this.userInfo.postalCode}`);
if (!data.success) {
throw new Error(data.error || 'Failed to load representatives');
@ -478,14 +636,17 @@ class CampaignPage {
}
list.innerHTML = this.representatives.map(rep => `
<div class="rep-card">
<div class="rep-card ${rep.is_custom_recipient ? 'custom-recipient' : ''}">
<div class="rep-info">
${rep.photo_url ?
`<img src="${rep.photo_url}" alt="${rep.name}" class="rep-photo">` :
`<div class="rep-photo"></div>`
`<div class="rep-photo">${rep.is_custom_recipient ? '👤' : ''}</div>`
}
<div class="rep-details">
<h4>${rep.name}</h4>
<h4>
${rep.name}
${rep.is_custom_recipient ? '<span class="custom-badge" title="Custom Recipient">✉️</span>' : ''}
</h4>
<p>${rep.elected_office || 'Representative'}</p>
<p>${rep.party_name || ''}</p>
${rep.email ? `<p>📧 ${rep.email}</p>` : ''}
@ -498,11 +659,12 @@ class CampaignPage {
data-email="${rep.email}"
data-name="${rep.name}"
data-title="${rep.elected_office || ''}"
data-level="${this.getGovernmentLevel(rep)}">
data-level="${this.getGovernmentLevel(rep)}"
data-is-custom="${rep.is_custom_recipient || false}">
Send Email
</button>
` : ''}
${this.getPhoneNumber(rep) ? `
${this.getPhoneNumber(rep) && !rep.is_custom_recipient ? `
<button class="btn btn-success" data-action="call-representative"
data-phone="${this.getPhoneNumber(rep)}"
data-name="${rep.name}"
@ -530,7 +692,8 @@ class CampaignPage {
const name = e.target.dataset.name;
const title = e.target.dataset.title;
const level = e.target.dataset.level;
this.sendEmail(email, name, title, level);
const isCustom = e.target.dataset.isCustom === 'true';
this.sendEmail(email, name, title, level, isCustom);
});
});
@ -604,12 +767,7 @@ class CampaignPage {
async trackCall(phone, name, title, officeType) {
try {
const response = await fetch(`/api/campaigns/${this.campaignSlug}/track-call`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
const data = await window.apiClient.post(`/campaigns/${this.campaignSlug}/track-call`, {
representativeName: name,
representativeTitle: title || '',
phoneNumber: phone,
@ -617,10 +775,7 @@ class CampaignPage {
userEmail: this.userInfo.userEmail,
userName: this.userInfo.userName,
postalCode: this.userInfo.postalCode
})
});
const data = await response.json();
if (data.success) {
this.showCallSuccess('Call tracked successfully!');
}
@ -629,23 +784,26 @@ class CampaignPage {
}
}
async sendEmail(recipientEmail, recipientName, recipientTitle, recipientLevel) {
async sendEmail(recipientEmail, recipientName, recipientTitle, recipientLevel, isCustom = false) {
const emailMethod = document.querySelector('input[name="emailMethod"]:checked').value;
// Use "Custom Recipient" as level if this is a custom recipient and no level provided
const finalLevel = isCustom && !recipientLevel ? 'Custom Recipient' : recipientLevel;
if (emailMethod === 'mailto') {
this.openMailtoLink(recipientEmail);
this.openMailtoLink(recipientEmail, recipientName, recipientTitle, finalLevel);
} else {
await this.sendSMTPEmail(recipientEmail, recipientName, recipientTitle, recipientLevel);
await this.sendSMTPEmail(recipientEmail, recipientName, recipientTitle, finalLevel);
}
}
openMailtoLink(recipientEmail) {
openMailtoLink(recipientEmail, recipientName, recipientTitle, recipientLevel) {
const subject = encodeURIComponent(this.currentEmailSubject || this.campaign.email_subject);
const body = encodeURIComponent(this.currentEmailBody || this.campaign.email_body);
const mailtoUrl = `mailto:${recipientEmail}?subject=${subject}&body=${body}`;
// Track the mailto click
this.trackEmail(recipientEmail, '', '', '', 'mailto');
this.trackEmail(recipientEmail, recipientName, recipientTitle, recipientLevel, 'mailto');
window.open(mailtoUrl);
}
@ -671,15 +829,7 @@ class CampaignPage {
emailData.customEmailBody = this.currentEmailBody || this.campaign.email_body;
}
const response = await fetch(`/api/campaigns/${this.campaignSlug}/send-email`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(emailData)
});
const data = await response.json();
const data = await window.apiClient.post(`/campaigns/${this.campaignSlug}/send-email`, emailData);
if (data.success) {
this.showSuccess('Email sent successfully!');
@ -702,12 +852,7 @@ class CampaignPage {
async trackEmail(recipientEmail, recipientName, recipientTitle, recipientLevel, emailMethod) {
try {
await fetch(`/api/campaigns/${this.campaignSlug}/send-email`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
await window.apiClient.post(`/campaigns/${this.campaignSlug}/send-email`, {
userEmail: this.userInfo.userEmail,
userName: this.userInfo.userName,
postalCode: this.userInfo.postalCode,
@ -716,7 +861,6 @@ class CampaignPage {
recipientTitle,
recipientLevel,
emailMethod
})
});
} catch (error) {
console.error('Failed to track email:', error);

View File

@ -73,10 +73,26 @@ class CampaignsGrid {
const campaignsHTML = sortedCampaigns.map(campaign => this.renderCampaignCard(campaign)).join('');
this.container.innerHTML = campaignsHTML;
// Trigger animations by forcing a reflow
this.triggerAnimations();
// Add click event listeners to campaign cards (no inline handlers)
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() {
const campaignCards = this.container.querySelectorAll('.campaign-card');
campaignCards.forEach(card => {
@ -143,6 +159,14 @@ class CampaignsGrid {
</div>`
: '';
const verifiedResponseBadge = campaign.verifiedResponseCount > 0
? `<div class="campaign-card-stat verified-response">
<span class="stat-icon"></span>
<span class="stat-value">${campaign.verifiedResponseCount}</span>
<span class="stat-label">verified ${campaign.verifiedResponseCount === 1 ? 'response' : 'responses'}</span>
</div>`
: '';
const targetLevels = Array.isArray(campaign.target_government_levels) && campaign.target_government_levels.length > 0
? campaign.target_government_levels.map(level => `<span class="level-badge">${level}</span>`).join('')
: '';
@ -157,9 +181,9 @@ class CampaignsGrid {
<div class="campaign-card" data-slug="${campaign.slug}">
<div class="campaign-card-image" style="${coverPhotoStyle}">
<div class="campaign-card-overlay"></div>
<h3 class="campaign-card-title">${this.escapeHtml(campaign.title)}</h3>
</div>
<div class="campaign-card-content">
<h3 class="campaign-card-title">${this.escapeHtml(campaign.title)}</h3>
<p class="campaign-card-description">${this.escapeHtml(truncatedDescription)}</p>
${targetLevels ? `<div class="campaign-card-levels">${targetLevels}</div>` : ''}
<div class="campaign-card-stats">
@ -199,6 +223,7 @@ class CampaignsGrid {
</svg>
</button>
</div>
${verifiedResponseBadge}
<div class="campaign-card-action">
<span class="btn-link">Learn More & Participate </span>
</div>

View File

@ -0,0 +1,525 @@
/**
* Custom Recipients Management Module
* Handles CRUD operations for custom email recipients in campaigns
*/
console.log('Custom Recipients module loading...');
const CustomRecipients = (() => {
console.log('Custom Recipients module initialized');
let currentCampaignSlug = null;
let recipients = [];
/**
* Initialize the module with a campaign slug
*/
function init(campaignSlug) {
console.log('CustomRecipients.init() called with slug:', campaignSlug);
currentCampaignSlug = campaignSlug;
// Setup event listeners every time init is called
// Use setTimeout to ensure DOM is ready
setTimeout(() => {
setupEventListeners();
console.log('CustomRecipients event listeners set up');
}, 100);
}
/**
* Setup event listeners for custom recipients UI
*/
function setupEventListeners() {
console.log('Setting up CustomRecipients event listeners');
// Add recipient form submit
const addForm = document.getElementById('add-recipient-form');
console.log('Add recipient form found:', addForm);
if (addForm) {
// Remove any existing listener first
addForm.removeEventListener('submit', handleAddRecipient);
addForm.addEventListener('submit', handleAddRecipient);
console.log('Form submit listener attached');
}
// Bulk import button
const bulkImportBtn = document.getElementById('bulk-import-recipients-btn');
if (bulkImportBtn) {
bulkImportBtn.removeEventListener('click', openBulkImportModal);
bulkImportBtn.addEventListener('click', openBulkImportModal);
}
// Clear all recipients button
const clearAllBtn = document.getElementById('clear-all-recipients-btn');
if (clearAllBtn) {
clearAllBtn.removeEventListener('click', handleClearAll);
clearAllBtn.addEventListener('click', handleClearAll);
}
// Bulk import modal buttons
const importBtn = document.getElementById('import-recipients-btn');
if (importBtn) {
importBtn.removeEventListener('click', handleBulkImport);
importBtn.addEventListener('click', handleBulkImport);
}
const cancelBtn = document.querySelector('#bulk-import-modal .cancel');
if (cancelBtn) {
cancelBtn.removeEventListener('click', closeBulkImportModal);
cancelBtn.addEventListener('click', closeBulkImportModal);
}
// Close modal on backdrop click
const modal = document.getElementById('bulk-import-modal');
if (modal) {
modal.addEventListener('click', (e) => {
if (e.target === modal) {
closeBulkImportModal();
}
});
}
}
/**
* Load recipients for the current campaign
*/
async function loadRecipients(campaignSlug) {
console.log('loadRecipients() called with campaignSlug:', campaignSlug);
console.log('currentCampaignSlug:', currentCampaignSlug);
// Use provided slug or fall back to currentCampaignSlug
const slug = campaignSlug || currentCampaignSlug;
if (!slug) {
console.error('No campaign slug available to load recipients');
showMessage('No campaign selected', 'error');
return [];
}
try {
console.log('Fetching recipients from:', `/campaigns/${slug}/custom-recipients`);
const data = await window.apiClient.get(`/campaigns/${slug}/custom-recipients`);
console.log('Recipients data received:', data);
recipients = data.recipients || [];
console.log('Loaded recipients count:', recipients.length);
displayRecipients();
return recipients;
} catch (error) {
console.error('Error loading recipients:', error);
showMessage('Failed to load recipients: ' + error.message, 'error');
return [];
}
}
/**
* Display recipients list
*/
function displayRecipients() {
console.log('displayRecipients() called, recipients count:', recipients.length);
const container = document.getElementById('recipients-list');
console.log('Recipients container found:', container);
if (!container) {
console.error('Recipients list container not found!');
return;
}
if (recipients.length === 0) {
container.innerHTML = '<div class="empty-state">No custom recipients added yet. Use the form above to add recipients.</div>';
console.log('Displayed empty state');
return;
}
console.log('Rendering', recipients.length, 'recipients');
container.innerHTML = recipients.map(recipient => `
<div class="recipient-card" data-id="${recipient.id}">
<div class="recipient-info">
<div class="recipient-name">${escapeHtml(recipient.recipient_name)}</div>
<div class="recipient-email">${escapeHtml(recipient.recipient_email)}</div>
${recipient.recipient_title ? `<div class="recipient-meta">${escapeHtml(recipient.recipient_title)}</div>` : ''}
${recipient.recipient_organization ? `<div class="recipient-meta">${escapeHtml(recipient.recipient_organization)}</div>` : ''}
${recipient.notes ? `<div class="recipient-meta"><em>${escapeHtml(recipient.notes)}</em></div>` : ''}
</div>
<div class="recipient-actions">
<button class="btn-icon edit-recipient" data-id="${recipient.id}" title="Edit recipient">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M12.854.146a.5.5 0 0 0-.707 0L10.5 1.793 14.207 5.5l1.647-1.646a.5.5 0 0 0 0-.708l-3-3zm.646 6.061L9.793 2.5 3.293 9H3.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.207l6.5-6.5zm-7.468 7.468A.5.5 0 0 1 6 13.5V13h-.5a.5.5 0 0 1-.5-.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.5-.5V10h-.5a.499.499 0 0 1-.175-.032l-.179.178a.5.5 0 0 0-.11.168l-2 5a.5.5 0 0 0 .65.65l5-2a.5.5 0 0 0 .168-.11l.178-.178z"/>
</svg>
</button>
<button class="btn-icon delete-recipient" data-id="${recipient.id}" title="Delete recipient">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
</svg>
</button>
</div>
</div>
`).join('');
// Add event listeners to edit and delete buttons
container.querySelectorAll('.edit-recipient').forEach(btn => {
btn.addEventListener('click', (e) => {
const id = e.currentTarget.dataset.id;
handleEditRecipient(id);
});
});
container.querySelectorAll('.delete-recipient').forEach(btn => {
btn.addEventListener('click', (e) => {
const id = e.currentTarget.dataset.id;
handleDeleteRecipient(id);
});
});
}
/**
* Handle add recipient form submission
*/
async function handleAddRecipient(e) {
console.log('handleAddRecipient called, event:', e);
e.preventDefault();
console.log('Form submission prevented, currentCampaignSlug:', currentCampaignSlug);
const formData = {
recipient_name: document.getElementById('recipient-name').value.trim(),
recipient_email: document.getElementById('recipient-email').value.trim(),
recipient_title: document.getElementById('recipient-title').value.trim(),
recipient_organization: document.getElementById('recipient-organization').value.trim(),
notes: document.getElementById('recipient-notes').value.trim()
};
console.log('Form data collected:', formData);
// Validate email
if (!validateEmail(formData.recipient_email)) {
console.error('Email validation failed');
showMessage('Please enter a valid email address', 'error');
return;
}
console.log('Email validation passed');
try {
const url = `/campaigns/${currentCampaignSlug}/custom-recipients`;
console.log('Making POST request to:', url);
const data = await window.apiClient.post(url, formData);
console.log('Response data:', data);
showMessage('Recipient added successfully', 'success');
e.target.reset();
await loadRecipients(currentCampaignSlug);
} catch (error) {
console.error('Error adding recipient:', error);
showMessage('Failed to add recipient: ' + error.message, 'error');
}
}
/**
* Handle edit recipient
*/
async function handleEditRecipient(recipientId) {
const recipient = recipients.find(r => r.id == recipientId);
if (!recipient) return;
// Populate form with recipient data
document.getElementById('recipient-name').value = recipient.recipient_name || '';
document.getElementById('recipient-email').value = recipient.recipient_email || '';
document.getElementById('recipient-title').value = recipient.recipient_title || '';
document.getElementById('recipient-organization').value = recipient.recipient_organization || '';
document.getElementById('recipient-notes').value = recipient.notes || '';
// Change form behavior to update instead of create
const form = document.getElementById('add-recipient-form');
const submitBtn = form.querySelector('button[type="submit"]');
// Store the recipient ID for update
form.dataset.editingId = recipientId;
submitBtn.textContent = 'Update Recipient';
// Add cancel button if it doesn't exist
let cancelBtn = form.querySelector('.cancel-edit-btn');
if (!cancelBtn) {
cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
cancelBtn.className = 'btn secondary cancel-edit-btn';
cancelBtn.textContent = 'Cancel';
cancelBtn.addEventListener('click', cancelEdit);
submitBtn.parentNode.insertBefore(cancelBtn, submitBtn.nextSibling);
}
// Update form submit handler
form.removeEventListener('submit', handleAddRecipient);
form.addEventListener('submit', handleUpdateRecipient);
// Scroll to form
form.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
/**
* Handle update recipient
*/
async function handleUpdateRecipient(e) {
e.preventDefault();
const form = e.target;
const recipientId = form.dataset.editingId;
const formData = {
recipient_name: document.getElementById('recipient-name').value.trim(),
recipient_email: document.getElementById('recipient-email').value.trim(),
recipient_title: document.getElementById('recipient-title').value.trim(),
recipient_organization: document.getElementById('recipient-organization').value.trim(),
notes: document.getElementById('recipient-notes').value.trim()
};
// Validate email
if (!validateEmail(formData.recipient_email)) {
showMessage('Please enter a valid email address', 'error');
return;
}
try {
const data = await window.apiClient.put(`/campaigns/${currentCampaignSlug}/custom-recipients/${recipientId}`, formData);
showMessage('Recipient updated successfully', 'success');
cancelEdit();
await loadRecipients(currentCampaignSlug);
} catch (error) {
console.error('Error updating recipient:', error);
showMessage('Failed to update recipient: ' + error.message, 'error');
}
}
/**
* Cancel edit mode
*/
function cancelEdit() {
const form = document.getElementById('add-recipient-form');
const submitBtn = form.querySelector('button[type="submit"]');
const cancelBtn = form.querySelector('.cancel-edit-btn');
// Reset form
form.reset();
delete form.dataset.editingId;
submitBtn.textContent = 'Add Recipient';
// Remove cancel button
if (cancelBtn) {
cancelBtn.remove();
}
// Restore original submit handler
form.removeEventListener('submit', handleUpdateRecipient);
form.addEventListener('submit', handleAddRecipient);
}
/**
* Handle delete recipient
*/
async function handleDeleteRecipient(recipientId) {
if (!confirm('Are you sure you want to delete this recipient?')) {
return;
}
try {
const data = await window.apiClient.delete(`/campaigns/${currentCampaignSlug}/custom-recipients/${recipientId}`);
showMessage('Recipient deleted successfully', 'success');
await loadRecipients(currentCampaignSlug);
} catch (error) {
console.error('Error deleting recipient:', error);
showMessage('Failed to delete recipient: ' + error.message, 'error');
}
}
/**
* Handle clear all recipients
*/
async function handleClearAll() {
if (!confirm('Are you sure you want to delete ALL custom recipients for this campaign? This cannot be undone.')) {
return;
}
try {
const data = await window.apiClient.delete(`/campaigns/${currentCampaignSlug}/custom-recipients`);
showMessage(`Successfully deleted ${data.deletedCount} recipient(s)`, 'success');
await loadRecipients(currentCampaignSlug);
} catch (error) {
console.error('Error deleting all recipients:', error);
showMessage('Failed to delete recipients: ' + error.message, 'error');
}
}
/**
* Open bulk import modal
*/
function openBulkImportModal() {
const modal = document.getElementById('bulk-import-modal');
if (modal) {
modal.style.display = 'block';
// Clear previous results
document.getElementById('import-results').innerHTML = '';
document.getElementById('csv-file-input').value = '';
document.getElementById('csv-paste-input').value = '';
}
}
/**
* Close bulk import modal
*/
function closeBulkImportModal() {
const modal = document.getElementById('bulk-import-modal');
if (modal) {
modal.style.display = 'none';
}
}
/**
* Handle bulk import
*/
async function handleBulkImport() {
const fileInput = document.getElementById('csv-file-input');
const pasteInput = document.getElementById('csv-paste-input');
const resultsDiv = document.getElementById('import-results');
let csvText = '';
// Check file input first
if (fileInput.files.length > 0) {
const file = fileInput.files[0];
csvText = await readFileAsText(file);
} else if (pasteInput.value.trim()) {
csvText = pasteInput.value.trim();
} else {
resultsDiv.innerHTML = '<div class="error">Please select a CSV file or paste CSV data</div>';
return;
}
// Parse CSV
const parsedRecipients = parseCsv(csvText);
if (parsedRecipients.length === 0) {
resultsDiv.innerHTML = '<div class="error">No valid recipients found in CSV</div>';
return;
}
// Show loading
resultsDiv.innerHTML = '<div class="loading">Importing recipients...</div>';
try {
const data = await window.apiClient.post(`/campaigns/${currentCampaignSlug}/custom-recipients/bulk`, { recipients: parsedRecipients });
if (data.success) {
const { results } = data;
let html = `<div class="success">Successfully imported ${results.success.length} of ${results.total} recipients</div>`;
if (results.failed.length > 0) {
html += '<div class="failed-imports"><strong>Failed imports:</strong><ul>';
results.failed.forEach(failure => {
html += `<li>${escapeHtml(failure.recipient.recipient_name || 'Unknown')} (${escapeHtml(failure.recipient.recipient_email || 'No email')}): ${escapeHtml(failure.error)}</li>`;
});
html += '</ul></div>';
}
resultsDiv.innerHTML = html;
await loadRecipients(currentCampaignSlug);
// Close modal after 3 seconds if all successful
if (results.failed.length === 0) {
setTimeout(closeBulkImportModal, 3000);
}
} else {
throw new Error(data.error || 'Failed to import recipients');
}
} catch (error) {
console.error('Error importing recipients:', error);
resultsDiv.innerHTML = `<div class="error">Failed to import recipients: ${escapeHtml(error.message)}</div>`;
}
}
/**
* Parse CSV text into recipients array
*/
function parseCsv(csvText) {
const lines = csvText.split('\n').filter(line => line.trim());
const recipients = [];
// Skip header row if it exists
const startIndex = lines[0].toLowerCase().includes('recipient_name') ? 1 : 0;
for (let i = startIndex; i < lines.length; i++) {
const line = lines[i].trim();
if (!line) continue;
// Simple CSV parsing (doesn't handle quoted commas)
const parts = line.split(',').map(p => p.trim().replace(/^["']|["']$/g, ''));
if (parts.length >= 2) {
recipients.push({
recipient_name: parts[0],
recipient_email: parts[1],
recipient_title: parts[2] || '',
recipient_organization: parts[3] || '',
notes: parts[4] || ''
});
}
}
return recipients;
}
/**
* Read file as text
*/
function readFileAsText(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => resolve(e.target.result);
reader.onerror = (e) => reject(e);
reader.readAsText(file);
});
}
/**
* Validate email format
*/
function validateEmail(email) {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return re.test(email);
}
/**
* Escape HTML to prevent XSS
*/
function escapeHtml(text) {
const map = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#039;'
};
return text.replace(/[&<>"']/g, m => map[m]);
}
/**
* Show message to user
*/
function showMessage(message, type = 'info') {
// Try to use existing message display system
if (typeof window.showMessage === 'function') {
window.showMessage(message, type);
} else {
// Fallback to alert
alert(message);
}
}
// Public API
return {
init,
loadRecipients,
displayRecipients
};
})();
// Make available globally
window.CustomRecipients = CustomRecipients;

View File

@ -0,0 +1,465 @@
/**
* Admin Listmonk Management Functions for Influence System
* Handles admin interface for email list synchronization
*/
// Global variables for admin Listmonk functionality
let syncInProgress = false;
let syncProgressInterval = null;
/**
* Initialize Listmonk admin section
*/
async function initListmonkAdmin() {
await refreshListmonkStatus();
await loadListmonkStats();
}
/**
* Refresh the Listmonk sync status display
*/
async function refreshListmonkStatus() {
console.log('🔄 Refreshing Listmonk status...');
try {
const response = await fetch('/api/listmonk/status', {
credentials: 'include'
});
console.log('📡 Status response:', response.status, response.statusText);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const status = await response.json();
console.log('📊 Status data:', status);
updateStatusDisplay(status);
} catch (error) {
console.error('Failed to refresh Listmonk status:', error);
updateStatusDisplay({
enabled: false,
connected: false,
lastError: `Status check failed: ${error.message}`
});
}
}
/**
* Update the status display in the admin panel
*/
function updateStatusDisplay(status) {
console.log('🎨 Updating status display with:', status);
const connectionStatus = document.getElementById('connection-status');
const autosyncStatus = document.getElementById('autosync-status');
const lastError = document.getElementById('last-error');
console.log('🔍 Status elements found:', {
connectionStatus: !!connectionStatus,
autosyncStatus: !!autosyncStatus,
lastError: !!lastError
});
if (connectionStatus) {
if (status.enabled && status.connected) {
connectionStatus.innerHTML = '✅ <span style="color: #27ae60;">Connected</span>';
} else if (status.enabled) {
connectionStatus.innerHTML = '❌ <span style="color: #e74c3c;">Connection Failed</span>';
} else {
connectionStatus.innerHTML = '⭕ <span style="color: #95a5a6;">Disabled</span>';
}
console.log('✅ Connection status updated:', connectionStatus.innerHTML);
}
if (autosyncStatus) {
if (status.enabled) {
autosyncStatus.innerHTML = '✅ <span style="color: #27ae60;">Enabled</span>';
} else {
autosyncStatus.innerHTML = '⭕ <span style="color: #95a5a6;">Disabled</span>';
}
console.log('✅ Auto-sync status updated:', autosyncStatus.innerHTML);
}
if (lastError) {
if (status.lastError) {
lastError.style.display = 'block';
lastError.innerHTML = `<strong>⚠️ Last Error:</strong> ${escapeHtml(status.lastError)}`;
} else {
lastError.style.display = 'none';
}
console.log('✅ Last error updated:', lastError.innerHTML);
}
}
/**
* Load and display Listmonk list statistics
*/
async function loadListmonkStats() {
try {
const response = await fetch('/api/listmonk/stats', {
credentials: 'include'
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
console.log('📊 Stats API response:', data);
if (data.success && data.stats) {
displayListStats(data.stats);
} else {
console.error('Stats API returned unsuccessful response:', data);
displayListStats([]);
}
} catch (error) {
console.error('Failed to load Listmonk stats:', error);
}
}
/**
* Display list statistics in the admin panel
*/
function displayListStats(stats) {
const statsSection = document.getElementById('listmonk-stats-section');
if (!statsSection) return;
console.log('📊 displayListStats called with:', stats, 'Type:', typeof stats);
// Ensure stats is an array
const statsArray = Array.isArray(stats) ? stats : [];
console.log('📊 Stats array after conversion:', statsArray, 'Length:', statsArray.length);
// Clear existing stats
const existingStats = statsSection.querySelector('.stats-list');
if (existingStats) {
existingStats.remove();
}
// Create stats display
const statsList = document.createElement('div');
statsList.className = 'stats-list';
statsList.style.cssText = 'display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1rem;';
if (statsArray.length === 0) {
statsList.innerHTML = '<p style="color: #666; text-align: center; grid-column: 1/-1;">No email lists found or sync is disabled</p>';
} else {
statsArray.forEach(list => {
const statCard = document.createElement('div');
statCard.style.cssText = 'background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 1.5rem; border-radius: 8px; box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);';
statCard.innerHTML = `
<h4 style="margin: 0 0 0.5rem 0; font-size: 0.9rem; opacity: 0.9;">${escapeHtml(list.name)}</h4>
<p style="margin: 0; font-size: 2rem; font-weight: bold;">${list.subscriberCount || 0}</p>
<p style="margin: 0.25rem 0 0 0; font-size: 0.85rem; opacity: 0.9;">subscribers</p>
`;
statsList.appendChild(statCard);
});
}
statsSection.appendChild(statsList);
}
/**
* Sync data to Listmonk
* @param {string} type - 'participants', 'recipients', or 'all'
*/
async function syncToListmonk(type) {
if (syncInProgress) {
showNotification('Sync already in progress', 'warning');
return;
}
syncInProgress = true;
const progressSection = document.getElementById('sync-progress');
const resultsDiv = document.getElementById('sync-results');
const progressBar = document.getElementById('sync-progress-bar');
// Show progress section
if (progressSection) {
progressSection.style.display = 'block';
}
// Reset progress
if (progressBar) {
progressBar.style.width = '0%';
progressBar.textContent = '0%';
}
if (resultsDiv) {
resultsDiv.innerHTML = '<div class="sync-result info"><strong>⏳ Starting sync...</strong></div>';
}
// Disable sync buttons
const buttons = document.querySelectorAll('.sync-buttons .btn');
buttons.forEach(btn => {
btn.disabled = true;
btn.style.opacity = '0.6';
});
// Simulate progress
let progress = 0;
const progressInterval = setInterval(() => {
if (progress < 90) {
progress += Math.random() * 10;
if (progressBar) {
progressBar.style.width = `${Math.min(progress, 90)}%`;
progressBar.textContent = `${Math.floor(Math.min(progress, 90))}%`;
}
}
}, 200);
try {
let endpoint = '/api/listmonk/sync/';
let syncName = '';
switch(type) {
case 'participants':
endpoint += 'participants';
syncName = 'Campaign Participants';
break;
case 'recipients':
endpoint += 'recipients';
syncName = 'Custom Recipients';
break;
case 'all':
endpoint += 'all';
syncName = 'All Data';
break;
default:
throw new Error('Invalid sync type');
}
const response = await fetch(endpoint, {
method: 'POST',
credentials: 'include'
});
clearInterval(progressInterval);
if (progressBar) {
progressBar.style.width = '100%';
progressBar.textContent = '100%';
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
displaySyncResults(result, resultsDiv);
// Refresh stats after successful sync
await loadListmonkStats();
} catch (error) {
clearInterval(progressInterval);
console.error('Sync failed:', error);
if (resultsDiv) {
resultsDiv.innerHTML = `
<div class="sync-result error" style="background: #f8d7da; color: #721c24; padding: 1rem; border-radius: 8px; border-left: 4px solid #e74c3c;">
<strong> Sync Failed</strong>
<p style="margin: 0.5rem 0 0 0;">${escapeHtml(error.message)}</p>
</div>
`;
}
} finally {
syncInProgress = false;
// Re-enable sync buttons
buttons.forEach(btn => {
btn.disabled = false;
btn.style.opacity = '1';
});
}
}
/**
* Display sync results in the admin panel
*/
function displaySyncResults(result, resultsDiv) {
if (!resultsDiv) return;
let html = '';
if (result.success) {
html += `<div class="sync-result success" style="background: #d4edda; color: #155724; padding: 1rem; border-radius: 8px; border-left: 4px solid #27ae60; margin-bottom: 1rem;">
<strong> ${escapeHtml(result.message || 'Sync completed successfully')}</strong>
</div>`;
if (result.results) {
// Handle different result structures
if (result.results.participants || result.results.customRecipients) {
// Multi-type sync (all)
if (result.results.participants) {
html += formatSyncResults('Campaign Participants', result.results.participants);
}
if (result.results.customRecipients) {
html += formatSyncResults('Custom Recipients', result.results.customRecipients);
}
} else {
// Single type sync
html += formatSyncResults('Results', result.results);
}
}
} else {
html += `<div class="sync-result error" style="background: #f8d7da; color: #721c24; padding: 1rem; border-radius: 8px; border-left: 4px solid #e74c3c;">
<strong> Sync Failed</strong>
<p style="margin: 0.5rem 0 0 0;">${escapeHtml(result.error || result.message || 'Unknown error')}</p>
</div>`;
}
resultsDiv.innerHTML = html;
}
/**
* Format sync results for display
*/
function formatSyncResults(type, results) {
let html = `<div class="sync-result info" style="background: #e3f2fd; padding: 1rem; border-radius: 8px; border-left: 4px solid #2196f3; margin-bottom: 0.5rem;">
<strong>📊 ${escapeHtml(type)}:</strong>
<span style="color: #27ae60; font-weight: bold;">${results.success} succeeded</span>,
<span style="color: #e74c3c; font-weight: bold;">${results.failed} failed</span>
(${results.total} total)
</div>`;
// Show errors if any
if (results.errors && results.errors.length > 0) {
const maxErrors = 5; // Show max 5 errors
const errorCount = results.errors.length;
html += `<div class="sync-result warning" style="background: #fff3cd; color: #856404; padding: 1rem; border-radius: 8px; border-left: 4px solid #f39c12; margin-bottom: 0.5rem;">
<strong> Errors (showing ${Math.min(errorCount, maxErrors)} of ${errorCount}):</strong>
<ul style="margin: 0.5rem 0 0 0; padding-left: 1.5rem;">`;
results.errors.slice(0, maxErrors).forEach(error => {
html += `<li style="margin: 0.25rem 0;">${escapeHtml(error.email || 'Unknown')}: ${escapeHtml(error.error || 'Unknown error')}</li>`;
});
html += '</ul></div>';
}
return html;
}
/**
* Test Listmonk connection
*/
async function testListmonkConnection() {
try {
const response = await fetch('/api/listmonk/test-connection', {
method: 'POST',
credentials: 'include'
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
if (result.success) {
showNotification('✅ Listmonk connection successful!', 'success');
await refreshListmonkStatus();
} else {
showNotification(`❌ Connection failed: ${result.message}`, 'error');
}
} catch (error) {
console.error('Connection test failed:', error);
showNotification(`❌ Connection test failed: ${error.message}`, 'error');
}
}
/**
* Reinitialize Listmonk lists
*/
async function reinitializeListmonk() {
if (!confirm('⚠️ This will recreate all email lists. Are you sure?')) {
return;
}
try {
const response = await fetch('/api/listmonk/reinitialize', {
method: 'POST',
credentials: 'include'
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const result = await response.json();
if (result.success) {
showNotification('✅ Listmonk lists reinitialized successfully!', 'success');
await refreshListmonkStatus();
await loadListmonkStats();
} else {
showNotification(`❌ Reinitialization failed: ${result.message}`, 'error');
}
} catch (error) {
console.error('Reinitialization failed:', error);
showNotification(`❌ Reinitialization failed: ${error.message}`, 'error');
}
}
/**
* Utility function to escape HTML
*/
function escapeHtml(text) {
if (typeof text !== 'string') return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Show notification message
*/
function showNotification(message, type = 'info') {
const messageContainer = document.getElementById('message-container');
if (!messageContainer) {
// Fallback to alert if container not found
alert(message);
return;
}
const alertClass = type === 'success' ? 'message-success' :
type === 'error' ? 'message-error' :
'message-info';
messageContainer.className = alertClass;
messageContainer.textContent = message;
messageContainer.classList.remove('hidden');
// Auto-hide after 5 seconds
setTimeout(() => {
messageContainer.classList.add('hidden');
}, 5000);
}
// Initialize Listmonk admin when tab is activated
document.addEventListener('DOMContentLoaded', () => {
// Watch for tab changes
const listmonkTab = document.querySelector('[data-tab="listmonk"]');
if (listmonkTab) {
listmonkTab.addEventListener('click', () => {
// Initialize on first load
if (!listmonkTab.dataset.initialized) {
initListmonkAdmin();
listmonkTab.dataset.initialized = 'true';
}
});
}
});
// Export functions for global use
window.syncToListmonk = syncToListmonk;
window.refreshListmonkStatus = refreshListmonkStatus;
window.testListmonkConnection = testListmonkConnection;
window.reinitializeListmonk = reinitializeListmonk;
window.initListmonkAdmin = initListmonkAdmin;

View File

@ -4,13 +4,23 @@ class MainApp {
this.init();
}
init() {
async init() {
// Initialize message display system
window.messageDisplay = new MessageDisplay();
// Check API health on startup
this.checkAPIHealth();
// Initialize postal lookup immediately (always show it first)
this.postalLookup = new PostalLookup(this.updateRepresentatives.bind(this));
// Check for highlighted campaign FIRST (before campaigns grid)
await this.checkHighlightedCampaign();
// Initialize campaigns grid AFTER highlighted campaign loads
this.campaignsGrid = new CampaignsGrid();
await this.campaignsGrid.init();
// Add global error handling
window.addEventListener('error', (e) => {
// Only log and show message for actual errors, not null/undefined
@ -57,6 +67,119 @@ class MainApp {
window.messageDisplay.show('Connection to server failed. Please check your internet connection and try again.', 'error');
}
}
async checkHighlightedCampaign() {
try {
const response = await fetch('/api/public/highlighted-campaign');
if (!response.ok) {
if (response.status === 404) {
// No highlighted campaign, show normal postal code lookup
return false;
}
throw new Error('Failed to fetch highlighted campaign');
}
const data = await response.json();
if (data.success && data.campaign) {
this.displayHighlightedCampaign(data.campaign);
return true;
}
return false;
} catch (error) {
console.error('Error checking for highlighted campaign:', error);
// Continue with normal postal code lookup if there's an error
return false;
}
}
displayHighlightedCampaign(campaign) {
const highlightedSection = document.getElementById('highlighted-campaign-section');
const highlightedContainer = document.getElementById('highlighted-campaign-container');
if (!highlightedSection || !highlightedContainer) return;
// Build the campaign display HTML with cover photo
const coverPhotoStyle = campaign.cover_photo
? `background-image: linear-gradient(rgba(0, 0, 0, 0.3), rgba(0, 0, 0, 0.3)), url('/uploads/${campaign.cover_photo}'); background-size: cover; background-position: center;`
: 'background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);';
const statsHTML = [];
if (campaign.show_email_count && campaign.emailCount !== null) {
statsHTML.push(`<div class="stat"><span class="stat-icon">📧</span><strong>${campaign.emailCount}</strong> Emails Sent</div>`);
}
if (campaign.show_call_count && campaign.callCount !== null) {
statsHTML.push(`<div class="stat"><span class="stat-icon">📞</span><strong>${campaign.callCount}</strong> Calls Made</div>`);
}
if (campaign.show_response_count && campaign.responseCount !== null) {
statsHTML.push(`<div class="stat"><span class="stat-icon">✅</span><strong>${campaign.responseCount}</strong> Responses</div>`);
}
const highlightedHTML = `
<div class="highlighted-campaign-container">
${campaign.cover_photo ? `
<div class="highlighted-campaign-header" style="${coverPhotoStyle}">
<div class="highlighted-campaign-badge"> Featured Campaign</div>
<h2>${this.escapeHtml(campaign.title || campaign.name)}</h2>
</div>
` : `
<div class="highlighted-campaign-badge"> Featured Campaign</div>
<h2>${this.escapeHtml(campaign.title || campaign.name)}</h2>
`}
<div class="highlighted-campaign-content">
${campaign.description ? `<p class="campaign-description">${this.escapeHtml(campaign.description)}</p>` : ''}
${statsHTML.length > 0 ? `
<div class="campaign-stats-inline">
${statsHTML.join('')}
</div>
` : ''}
<div class="campaign-cta">
<a href="/campaign/${campaign.slug}" class="btn btn-primary btn-large">
Join This Campaign
</a>
</div>
</div>
</div>
`;
// Insert the HTML
highlightedContainer.innerHTML = highlightedHTML;
// Make section visible but collapsed
highlightedSection.style.display = 'grid';
// Force a reflow to ensure the initial state is applied
const height = highlightedSection.offsetHeight;
console.log('Campaign section initial height:', height);
// Wait a bit longer before starting animation to ensure it's visible
setTimeout(() => {
console.log('Starting campaign expansion animation...');
highlightedSection.classList.add('show');
// Add animation to the container after expansion starts
setTimeout(() => {
const container = highlightedContainer.querySelector('.highlighted-campaign-container');
if (container) {
console.log('Adding visible class to container...');
container.classList.add('visible', 'fade-in-smooth');
}
}, 300);
}, 100);
}
updateRepresentatives(representatives) {
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
// Message Display System

View File

@ -15,7 +15,12 @@ class PostalLookup {
init() {
this.form.addEventListener('submit', (e) => this.handleSubmit(e));
// Only add refresh button listener if it exists
if (this.refreshBtn) {
this.refreshBtn.addEventListener('click', () => this.handleRefresh());
}
this.input.addEventListener('input', (e) => this.formatPostalCode(e));
}

View File

@ -341,16 +341,46 @@ class RepresentativesDisplay {
}
getShortAddress(address) {
// Clean the address first
const cleaned = this.cleanAddress(address);
// Extract city and province/state for short display
const parts = address.split(',');
const parts = cleaned.split(',').map(p => p.trim()).filter(p => p);
if (parts.length >= 2) {
const city = parts[parts.length - 2].trim();
const province = parts[parts.length - 1].trim();
const city = parts[parts.length - 2];
const province = parts[parts.length - 1];
return `${city}, ${province}`;
}
// Fallback: just show first part
return parts[0].trim();
// Fallback: just show the cleaned address
return parts[0] || cleaned;
}
cleanAddress(address) {
if (!address) return '';
// Split by newlines and process each line
const lines = address.split('\n').map(line => line.trim()).filter(line => line);
// Remove common prefixes and metadata
const filteredLines = lines.filter(line => {
const lower = line.toLowerCase();
// Skip lines that are just descriptive text
return !lower.startsWith('main office') &&
!lower.startsWith('constituency office') &&
!lower.startsWith('legislature office') &&
!lower.startsWith('district office') &&
!lower.startsWith('local office') &&
!lower.startsWith('office:') &&
!line.match(/^[a-z\s]+\s*-\s*/i); // Remove "Main office - City" patterns
});
// If we filtered everything out, use original lines minus obvious prefixes
const addressLines = filteredLines.length > 0 ? filteredLines : lines.slice(1);
// Join remaining lines with commas
return addressLines.join(', ').trim();
}
attachEventListeners() {
@ -463,7 +493,7 @@ class RepresentativesDisplay {
handleVisitClick(address, name, office) {
// Clean and format the address for URL encoding
const cleanAddress = address.replace(/\n/g, ', ').trim();
const cleanAddress = this.cleanAddress(address);
// Show confirmation dialog
const message = `Open directions to ${name}'s office?\n\nAddress: ${cleanAddress}`;

View File

@ -85,7 +85,7 @@ function clearRepresentativeMarkers() {
}
// Add representative offices to the map
function displayRepresentativeOffices(representatives, postalCode) {
async function displayRepresentativeOffices(representatives, postalCode) {
// Initialize map if not already done
if (!representativesMap) {
console.log('Map not initialized, initializing now...');
@ -105,14 +105,18 @@ function displayRepresentativeOffices(representatives, postalCode) {
console.log('Processing representatives for map display:', representatives.length);
// Show geocoding progress
showMapMessage(`Locating ${representatives.length} office${representatives.length > 1 ? 's' : ''}...`);
// Group representatives by office location to handle shared addresses
const locationGroups = new Map();
representatives.forEach((rep, index) => {
console.log(`Processing representative ${index + 1}:`, rep.name, rep.representative_set_name);
// Process all representatives and geocode their offices
for (const rep of representatives) {
console.log(`Processing representative:`, rep.name, rep.representative_set_name);
// Try to get office location from various sources
const offices = getOfficeLocations(rep);
// Get office location (now async for geocoding)
const offices = await getOfficeLocations(rep);
console.log(`Found ${offices.length} offices for ${rep.name}:`, offices);
offices.forEach((office, officeIndex) => {
@ -139,14 +143,22 @@ function displayRepresentativeOffices(representatives, postalCode) {
console.log(`No coordinates found for ${rep.name} office:`, office);
}
});
});
}
// Clear the loading message
const mapContainer = document.getElementById('main-map');
const existingMessage = mapContainer?.querySelector('.map-message');
if (existingMessage) {
existingMessage.remove();
}
// Create markers for each location group
let offsetIndex = 0;
locationGroups.forEach((locationGroup, locationKey) => {
console.log(`Creating markers for location ${locationKey} with ${locationGroup.representatives.length} representatives`);
const numReps = locationGroup.representatives.length;
console.log(`Creating markers for location ${locationKey} with ${numReps} representatives`);
if (locationGroup.representatives.length === 1) {
if (numReps === 1) {
// Single representative at this location
const rep = locationGroup.representatives[0];
const office = locationGroup.offices[0];
@ -157,36 +169,58 @@ function displayRepresentativeOffices(representatives, postalCode) {
bounds.push([office.lat, office.lng]);
}
} else {
// Multiple representatives at same location - create offset markers
// Multiple representatives at same location - create offset markers in a circle
locationGroup.representatives.forEach((rep, repIndex) => {
const office = locationGroup.offices[repIndex];
// Add small offset to avoid exact overlap
const offsetDistance = 0.0005; // About 50 meters
const angle = (repIndex * 2 * Math.PI) / locationGroup.representatives.length;
// Increase offset distance based on number of representatives
// More reps = larger circle for better visibility
const baseDistance = 0.001; // About 100 meters base
const offsetDistance = baseDistance * (1 + (numReps / 10)); // Scale with count
// Arrange in a circle around the point
const angle = (repIndex * 2 * Math.PI) / numReps;
const offsetLat = office.lat + (offsetDistance * Math.cos(angle));
const offsetLng = office.lng + (offsetDistance * Math.sin(angle));
const offsetOffice = {
...office,
lat: offsetLat,
lng: offsetLng
lng: offsetLng,
isOffset: true,
originalLat: office.lat,
originalLng: office.lng
};
console.log(`Creating offset marker for ${rep.name} at ${offsetLat}, ${offsetLng}`);
const marker = createOfficeMarker(rep, offsetOffice, locationGroup.representatives.length > 1);
console.log(`Creating offset marker ${repIndex + 1}/${numReps} for ${rep.name} at ${offsetLat}, ${offsetLng} (offset from ${office.lat}, ${office.lng})`);
const marker = createOfficeMarker(rep, offsetOffice, true);
if (marker) {
representativeMarkers.push(marker);
marker.addTo(representativesMap);
bounds.push([offsetLat, offsetLng]);
}
});
// Add the original center point to bounds as well
bounds.push([locationGroup.lat, locationGroup.lng]);
}
});
console.log(`Total markers created: ${representativeMarkers.length}`);
console.log(`Unique locations: ${locationGroups.size}`);
console.log(`Bounds array:`, bounds);
// Log summary of locations
const locationSummary = [];
locationGroups.forEach((group, key) => {
locationSummary.push({
location: key,
address: group.address.substring(0, 50) + '...',
representatives: group.representatives.map(r => r.name).join(', ')
});
});
console.table(locationSummary);
// Fit map to show all offices, or center on Alberta if no offices found
if (bounds.length > 0) {
representativesMap.fitBounds(bounds, { padding: [20, 20] });
@ -200,7 +234,7 @@ function displayRepresentativeOffices(representatives, postalCode) {
}
// Extract office locations from representative data
function getOfficeLocations(representative) {
async function getOfficeLocations(representative) {
const offices = [];
console.log(`Getting office locations for ${representative.name}`);
@ -208,8 +242,8 @@ function getOfficeLocations(representative) {
// Check various sources for office location data
if (representative.offices && Array.isArray(representative.offices)) {
representative.offices.forEach((office, index) => {
console.log(`Processing office ${index + 1}:`, office);
for (const office of representative.offices) {
console.log(`Processing office:`, office);
// Use the 'postal' field which contains the address
if (office.postal || office.address) {
@ -225,28 +259,49 @@ function getOfficeLocations(representative) {
console.log('Created office data:', officeData);
offices.push(officeData);
}
});
}
}
// For all offices without coordinates, add approximate coordinates
offices.forEach(office => {
// For all offices without coordinates, try to geocode the address
for (const office of offices) {
if (!office.lat || !office.lng) {
console.log(`Adding coordinates to office for ${representative.name}`);
const approxLocation = getApproximateLocationByDistrict(representative.district_name, representative.representative_set_name);
console.log(`Geocoding address for ${representative.name}: ${office.address}`);
// Try geocoding the actual address first
const geocoded = await geocodeWithRateLimit(office.address);
if (geocoded) {
office.lat = geocoded.lat;
office.lng = geocoded.lng;
console.log('Geocoded office:', office);
} else {
// Fallback to city-level approximation
console.log(`Geocoding failed, using city approximation for ${representative.name}`);
const approxLocation = getApproximateLocationByDistrict(
representative.district_name,
representative.representative_set_name,
office.address
);
console.log('Approximate location:', approxLocation);
if (approxLocation) {
office.lat = approxLocation.lat;
office.lng = approxLocation.lng;
console.log('Updated office with coordinates:', office);
console.log('Updated office with approximate coordinates:', office);
}
}
}
}
});
// If no offices found at all, create a fallback office
if (offices.length === 0 && representative.representative_set_name) {
console.log(`No offices found, creating fallback office for ${representative.name}`);
const approxLocation = getApproximateLocationByDistrict(representative.district_name, representative.representative_set_name);
// For fallback, try to get a better location based on district
const approxLocation = getApproximateLocationByDistrict(
representative.district_name,
representative.representative_set_name,
null // No address available for fallback
);
console.log('Approximate location:', approxLocation);
if (approxLocation) {
@ -265,32 +320,308 @@ function getOfficeLocations(representative) {
return offices;
}
// Get approximate location based on district and government level
function getApproximateLocationByDistrict(district, level) {
// Specific locations for Edmonton officials
const edmontonLocations = {
// City Hall for municipal officials
'Edmonton': { lat: 53.5444, lng: -113.4909 }, // Edmonton City Hall
"O-day'min": { lat: 53.5444, lng: -113.4909 }, // Edmonton City Hall
// Provincial Legislature
'Edmonton-Glenora': { lat: 53.5344, lng: -113.5065 }, // Alberta Legislature
// Federal offices (approximate downtown Edmonton)
'Edmonton Centre': { lat: 53.5461, lng: -113.4938 }
};
// Geocoding cache to avoid repeated API calls
const geocodingCache = new Map();
// Try specific district first
if (district && edmontonLocations[district]) {
return edmontonLocations[district];
// Clean and normalize address for geocoding
function normalizeAddressForGeocoding(address) {
if (!address) return '';
// Special handling for well-known government buildings
const lowerAddress = address.toLowerCase();
// Handle House of Commons / Parliament
if (lowerAddress.includes('house of commons') || lowerAddress.includes('parliament')) {
if (lowerAddress.includes('ottawa') || lowerAddress.includes('k1a')) {
return 'Parliament Hill, Ottawa, ON, Canada';
}
}
// Fallback based on government level
const levelLocations = {
'House of Commons': { lat: 53.5461, lng: -113.4938 }, // Downtown Edmonton
'Legislative Assembly of Alberta': { lat: 53.5344, lng: -113.5065 }, // Alberta Legislature
'Edmonton City Council': { lat: 53.5444, lng: -113.4909 } // Edmonton City Hall
// Handle Alberta Legislature
if (lowerAddress.includes('legislature') && (lowerAddress.includes('edmonton') || lowerAddress.includes('alberta'))) {
return '10800 97 Avenue NW, Edmonton, AB, Canada';
}
// Split by newlines
const lines = address.split('\n').map(line => line.trim()).filter(line => line);
// Remove lines that are just metadata/descriptive text
const filteredLines = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const lower = line.toLowerCase();
// Skip pure descriptive prefixes without addresses
if (lower.match(/^(main|local|district|constituency|legislature|regional)\s+(office|bureau)\s*-?\s*$/i)) {
continue; // Skip "Main office -" or "Constituency office" on their own
}
// Skip lines that are just "Main office - City" with no street address
if (lower.match(/^(main|local)\s+office\s*-\s*[a-z\s]+$/i) && !lower.match(/\d+/)) {
continue; // Skip if no street number
}
// Skip "Office:" prefixes
if (lower.match(/^office:\s*$/i)) {
continue;
}
// For lines starting with floor/suite/unit, try to extract just the street address
let cleanLine = line;
// Remove floor/suite/unit prefixes: "6th Floor, 123 Main St" -> "123 Main St"
cleanLine = cleanLine.replace(/^(suite|unit|floor|room|\d+(st|nd|rd|th)\s+floor)\s*,?\s*/i, '');
// Remove unit numbers at start: "#201, 123 Main St" -> "123 Main St"
cleanLine = cleanLine.replace(/^(#|unit|suite|ste\.?|apt\.?)\s*\d+[a-z]?\s*,\s*/i, '');
// Remove building names that precede addresses: "City Hall, 1 Main St" -> "1 Main St"
cleanLine = cleanLine.replace(/^(city hall|legislature building|federal building|provincial building),\s*/i, '');
// Clean up common building name patterns if there's a street address following
if (i === 0 && lines.length > 1) {
// If first line is just a building name and we have more lines, skip it
if (lower.match(/^(city hall|legislature|parliament|house of commons)$/i)) {
continue;
}
}
// Add the cleaned line if it has substance (contains a number for street address)
if (cleanLine.trim() && (cleanLine.match(/\d/) || cleanLine.match(/(edmonton|calgary|ottawa|alberta)/i))) {
filteredLines.push(cleanLine.trim());
}
}
// If we filtered everything, try a more lenient approach
if (filteredLines.length === 0) {
// Just join all lines and do basic cleanup
return lines
.map(line => line.replace(/^(main|local|district|constituency)\s+(office\s*-?\s*)/i, ''))
.filter(line => line.trim())
.join(', ') + ', Canada';
}
// Build cleaned address
let cleanAddress = filteredLines.join(', ');
// Fix Edmonton-style addresses: "9820 - 107 Street" -> "9820 107 Street"
cleanAddress = cleanAddress.replace(/(\d+)\s*-\s*(\d+\s+(Street|Avenue|Ave|St|Road|Rd|Drive|Dr|Boulevard|Blvd|Way|Lane|Ln))/gi, '$1 $2');
// Ensure it ends with "Canada" for better geocoding
if (!cleanAddress.toLowerCase().includes('canada')) {
cleanAddress += ', Canada';
}
return cleanAddress;
}
// Geocode an address using our backend API (which proxies to Nominatim)
async function geocodeAddress(address) {
// Check cache first
const cacheKey = address.toLowerCase().trim();
if (geocodingCache.has(cacheKey)) {
console.log(`Using cached coordinates for: ${address}`);
return geocodingCache.get(cacheKey);
}
try {
// Clean and normalize the address for better geocoding
const cleanedAddress = normalizeAddressForGeocoding(address);
console.log(`Original address: ${address}`);
console.log(`Cleaned address for geocoding: ${cleanedAddress}`);
// Call our backend geocoding endpoint
const response = await fetch('/api/geocode', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ address: cleanedAddress })
});
if (!response.ok) {
console.warn(`Geocoding API error: ${response.status}`);
return null;
}
const data = await response.json();
if (data.success && data.data && data.data.lat && data.data.lng) {
const coords = {
lat: data.data.lat,
lng: data.data.lng
};
return levelLocations[level] || { lat: 53.9333, lng: -116.5765 }; // Default to Alberta center
console.log(`✓ Geocoded "${cleanedAddress}" to:`, coords);
console.log(` Display name: ${data.data.display_name}`);
// Cache the result using the original address as key
geocodingCache.set(cacheKey, coords);
return coords;
} else {
console.log(`✗ No geocoding results for: ${cleanedAddress}`);
return null;
}
} catch (error) {
console.error(`Geocoding error for "${address}":`, error);
return null;
}
}
// Rate limiter for geocoding requests (Nominatim has a 1 request/second limit)
let lastGeocodeTime = 0;
const GEOCODE_DELAY = 1100; // 1.1 seconds between requests
async function geocodeWithRateLimit(address) {
const now = Date.now();
const timeSinceLastRequest = now - lastGeocodeTime;
if (timeSinceLastRequest < GEOCODE_DELAY) {
const waitTime = GEOCODE_DELAY - timeSinceLastRequest;
console.log(`Rate limiting: waiting ${waitTime}ms before geocoding`);
await new Promise(resolve => setTimeout(resolve, waitTime));
}
lastGeocodeTime = Date.now();
return await geocodeAddress(address);
}
// Alberta city coordinates lookup table (used as fallback)
const albertaCityCoordinates = {
// Major cities
'Edmonton': { lat: 53.5461, lng: -113.4938 },
'Calgary': { lat: 51.0447, lng: -114.0719 },
'Red Deer': { lat: 52.2681, lng: -113.8111 },
'Lethbridge': { lat: 49.6942, lng: -112.8328 },
'Medicine Hat': { lat: 50.0408, lng: -110.6775 },
'Grande Prairie': { lat: 55.1708, lng: -118.7947 },
'Airdrie': { lat: 51.2917, lng: -114.0144 },
'Fort McMurray': { lat: 56.7267, lng: -111.3790 },
'Spruce Grove': { lat: 53.5450, lng: -113.9006 },
'Okotoks': { lat: 50.7251, lng: -113.9778 },
'Leduc': { lat: 53.2594, lng: -113.5517 },
'Lloydminster': { lat: 53.2782, lng: -110.0053 },
'Camrose': { lat: 53.0167, lng: -112.8233 },
'Brooks': { lat: 50.5644, lng: -111.8986 },
'Cold Lake': { lat: 54.4639, lng: -110.1825 },
'Wetaskiwin': { lat: 52.9692, lng: -113.3769 },
'Stony Plain': { lat: 53.5267, lng: -114.0069 },
'Sherwood Park': { lat: 53.5344, lng: -113.3169 },
'St. Albert': { lat: 53.6303, lng: -113.6258 },
'Beaumont': { lat: 53.3572, lng: -113.4147 },
'Cochrane': { lat: 51.1942, lng: -114.4686 },
'Canmore': { lat: 51.0886, lng: -115.3581 },
'Banff': { lat: 51.1784, lng: -115.5708 },
'Jasper': { lat: 52.8737, lng: -118.0814 },
'Hinton': { lat: 53.4053, lng: -117.5856 },
'Whitecourt': { lat: 54.1433, lng: -115.6856 },
'Slave Lake': { lat: 55.2828, lng: -114.7728 },
'High River': { lat: 50.5792, lng: -113.8744 },
'Strathmore': { lat: 51.0364, lng: -113.4006 },
'Chestermere': { lat: 51.0506, lng: -113.8228 },
'Fort Saskatchewan': { lat: 53.7103, lng: -113.2192 },
'Lacombe': { lat: 52.4678, lng: -113.7372 },
'Sylvan Lake': { lat: 52.3081, lng: -114.0958 },
'Taber': { lat: 49.7850, lng: -112.1508 },
'Drayton Valley': { lat: 53.2233, lng: -114.9819 },
'Westlock': { lat: 54.1508, lng: -113.8631 },
'Ponoka': { lat: 52.6772, lng: -113.5836 },
'Morinville': { lat: 53.8022, lng: -113.6497 },
'Vermilion': { lat: 53.3553, lng: -110.8583 },
'Drumheller': { lat: 51.4633, lng: -112.7086 },
'Peace River': { lat: 56.2364, lng: -117.2892 },
'High Prairie': { lat: 55.4358, lng: -116.4856 },
'Athabasca': { lat: 54.7192, lng: -113.2856 },
'Bonnyville': { lat: 54.2681, lng: -110.7431 },
'Vegreville': { lat: 53.4944, lng: -112.0494 },
'Innisfail': { lat: 52.0358, lng: -113.9503 },
'Provost': { lat: 52.3547, lng: -110.2681 },
'Olds': { lat: 51.7928, lng: -114.1064 },
'Pincher Creek': { lat: 49.4858, lng: -113.9506 },
'Cardston': { lat: 49.1983, lng: -113.3028 },
'Crowsnest Pass': { lat: 49.6372, lng: -114.4831 },
// Capital references
'Ottawa': { lat: 45.4215, lng: -75.6972 }, // For federal legislature offices
'AB': { lat: 53.9333, lng: -116.5765 } // Alberta center
};
// Parse city from office address
function parseCityFromAddress(addressString) {
if (!addressString) return null;
// Common patterns in addresses
const lines = addressString.split('\n').map(line => line.trim()).filter(line => line);
// Check each line for city names
for (const line of lines) {
// Look for city names in our lookup table
for (const city in albertaCityCoordinates) {
if (line.includes(city)) {
console.log(`Found city "${city}" in address line: "${line}"`);
return city;
}
}
// Check for "City, Province" pattern
const cityProvinceMatch = line.match(/^([^,]+),\s*(AB|Alberta)/i);
if (cityProvinceMatch) {
const cityName = cityProvinceMatch[1].trim();
console.log(`Extracted city from province pattern: "${cityName}"`);
// Try to find this in our lookup
for (const city in albertaCityCoordinates) {
if (cityName.toLowerCase().includes(city.toLowerCase()) ||
city.toLowerCase().includes(cityName.toLowerCase())) {
return city;
}
}
}
}
return null;
}
// Get approximate location based on office address, district, and government level
function getApproximateLocationByDistrict(district, level, officeAddress = null) {
console.log(`Getting approximate location for district: ${district}, level: ${level}, address: ${officeAddress}`);
// First, try to parse city from office address
if (officeAddress) {
const city = parseCityFromAddress(officeAddress);
if (city && albertaCityCoordinates[city]) {
console.log(`Using coordinates for city: ${city}`);
return albertaCityCoordinates[city];
}
}
// Try to extract city from district name
if (district) {
// Check if district contains a city name
for (const city in albertaCityCoordinates) {
if (district.includes(city)) {
console.log(`Found city "${city}" in district name: "${district}"`);
return albertaCityCoordinates[city];
}
}
}
// Fallback based on government level and typical office locations
const levelLocations = {
'House of Commons': albertaCityCoordinates['Ottawa'], // Federal = Ottawa
'Legislative Assembly of Alberta': { lat: 53.5344, lng: -113.5065 }, // Provincial = Legislature
'Edmonton City Council': { lat: 53.5444, lng: -113.4909 } // Municipal = City Hall
};
if (level && levelLocations[level]) {
console.log(`Using level-based location for: ${level}`);
return levelLocations[level];
}
// Last resort: Alberta center
console.log('Using default Alberta center location');
return albertaCityCoordinates['AB'];
}
// Create a marker for an office location
@ -326,6 +657,16 @@ function createOfficePopupContent(representative, office, isSharedLocation = fal
const level = getRepresentativeLevel(representative.representative_set_name);
const levelClass = level.toLowerCase().replace(' ', '-');
// Show note if this is an offset marker at a shared location
const locationNote = isSharedLocation
? '<p class="shared-location-note"><small><em>📍 Shared office location with other representatives</em></small></p>'
: '';
// If office has original coordinates, show actual address
const addressDisplay = office.isOffset
? `<p><strong>Address:</strong> ${office.address}</p><p><small><em>Note: Marker positioned nearby for visibility</em></small></p>`
: office.address ? `<p><strong>Address:</strong> ${office.address}</p>` : '';
return `
<div class="office-popup-content">
<div class="rep-header ${levelClass}">
@ -334,13 +675,13 @@ function createOfficePopupContent(representative, office, isSharedLocation = fal
<h4>${representative.name}</h4>
<p class="rep-level">${level}</p>
<p class="rep-district">${representative.district_name || 'District not specified'}</p>
${isSharedLocation ? '<p class="shared-location-note"><small><em>Note: Office location shared with other representatives</em></small></p>' : ''}
${locationNote}
</div>
</div>
<div class="office-details">
<h5>Office Information</h5>
${office.address ? `<p><strong>Address:</strong> ${office.address}</p>` : ''}
${addressDisplay}
${office.phone ? `<p><strong>Phone:</strong> <a href="tel:${office.phone}">${office.phone}</a></p>` : ''}
${office.fax ? `<p><strong>Fax:</strong> ${office.fax}</p>` : ''}
${office.postal_code ? `<p><strong>Postal Code:</strong> ${office.postal_code}</p>` : ''}
@ -437,11 +778,11 @@ async function handlePostalCodeSubmission(postalCode) {
const response = await fetch(`/api/representatives/by-postal/${normalizedPostalCode}`);
const data = await response.json();
hideLoading();
if (data.success && data.data && data.data.representatives) {
// Display representatives on map
displayRepresentativeOffices(data.data.representatives, normalizedPostalCode);
// Display representatives on map (now async for geocoding)
await displayRepresentativeOffices(data.data.representatives, normalizedPostalCode);
hideLoading();
// Also update the representatives display section using the existing system
if (window.representativesDisplay) {
@ -495,6 +836,7 @@ async function handlePostalCodeSubmission(postalCode) {
window.messageDisplay.show(`Found ${data.data.representatives.length} representatives for ${normalizedPostalCode}`, 'success', 3000);
}
} else {
hideLoading();
showError(data.message || 'Unable to find representatives for this postal code.');
}
} catch (error) {

View File

@ -1,10 +1,12 @@
// Response Wall JavaScript
let currentCampaignSlug = null;
let currentCampaign = null;
let currentOffset = 0;
let currentSort = 'recent';
let currentLevel = '';
const LIMIT = 20;
let loadedRepresentatives = [];
// Initialize
document.addEventListener('DOMContentLoaded', () => {
@ -73,26 +75,514 @@ document.addEventListener('DOMContentLoaded', () => {
form.addEventListener('submit', handleSubmitResponse);
}
// Postal code lookup button
const lookupBtn = document.getElementById('lookup-rep-btn');
if (lookupBtn) {
lookupBtn.addEventListener('click', handlePostalLookup);
}
// Postal code input formatting
const postalInput = document.getElementById('modal-postal-code');
if (postalInput) {
postalInput.addEventListener('input', formatPostalCodeInput);
postalInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
handlePostalLookup();
}
});
}
// Representative selection
const repSelect = document.getElementById('rep-select');
if (repSelect) {
repSelect.addEventListener('change', handleRepresentativeSelect);
}
console.log('Response Wall: Initialization complete');
});
// Load response statistics
// Postal Code Lookup Functions
function formatPostalCodeInput(e) {
let value = e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '');
// Format as A1A 1A1
if (value.length > 3) {
value = value.slice(0, 3) + ' ' + value.slice(3, 6);
}
e.target.value = value;
}
function validatePostalCode(postalCode) {
const cleaned = postalCode.replace(/\s/g, '');
// Check format: Letter-Number-Letter Number-Letter-Number
const regex = /^[A-Z]\d[A-Z]\d[A-Z]\d$/;
if (!regex.test(cleaned)) {
return { valid: false, message: 'Please enter a valid postal code format (A1A 1A1)' };
}
// Check if it's an Alberta postal code (starts with T)
if (!cleaned.startsWith('T')) {
return { valid: false, message: 'This tool is designed for Alberta postal codes only (starting with T)' };
}
return { valid: true };
}
async function handlePostalLookup() {
const postalInput = document.getElementById('modal-postal-code');
const postalCode = postalInput.value.trim();
if (!postalCode) {
showError('Please enter a postal code');
return;
}
const validation = validatePostalCode(postalCode);
if (!validation.valid) {
showError(validation.message);
return;
}
const lookupBtn = document.getElementById('lookup-rep-btn');
lookupBtn.disabled = true;
lookupBtn.textContent = '🔄 Searching...';
try {
const response = await window.apiClient.getRepresentativesByPostalCode(postalCode);
const data = response.data || response;
loadedRepresentatives = data.representatives || [];
if (loadedRepresentatives.length === 0) {
showError('No representatives found for this postal code');
document.getElementById('rep-select-group').style.display = 'none';
} else {
displayRepresentativeOptions(loadedRepresentatives);
showSuccess(`Found ${loadedRepresentatives.length} representatives`);
}
} catch (error) {
console.error('Postal lookup failed:', error);
showError('Failed to lookup representatives: ' + error.message);
} finally {
lookupBtn.disabled = false;
lookupBtn.textContent = '🔍 Search';
}
}
function displayRepresentativeOptions(representatives) {
const repSelect = document.getElementById('rep-select');
const repSelectGroup = document.getElementById('rep-select-group');
// Clear existing options
repSelect.innerHTML = '';
// Add representatives as options
representatives.forEach((rep, index) => {
const option = document.createElement('option');
option.value = index;
// Format display text
let displayText = rep.name;
if (rep.district_name) {
displayText += ` - ${rep.district_name}`;
}
if (rep.party_name) {
displayText += ` (${rep.party_name})`;
}
displayText += ` [${rep.elected_office || 'Representative'}]`;
option.textContent = displayText;
repSelect.appendChild(option);
});
// Show the select group
repSelectGroup.style.display = 'block';
}
function handleRepresentativeSelect(e) {
const selectedIndex = e.target.value;
if (selectedIndex === '') return;
const rep = loadedRepresentatives[selectedIndex];
if (!rep) return;
// Auto-fill form fields
document.getElementById('representative-name').value = rep.name || '';
document.getElementById('representative-title').value = rep.elected_office || '';
// Set government level based on elected office
const level = determineGovernmentLevel(rep.elected_office);
document.getElementById('representative-level').value = level;
// Store email for verification option
if (rep.email) {
// Handle email being either string or array
const emailValue = Array.isArray(rep.email) ? rep.email[0] : rep.email;
document.getElementById('representative-email').value = emailValue;
// Enable verification checkbox if we have an email
const verificationCheckbox = document.getElementById('send-verification');
verificationCheckbox.disabled = false;
} else {
document.getElementById('representative-email').value = '';
// Disable verification checkbox if no email
const verificationCheckbox = document.getElementById('send-verification');
verificationCheckbox.disabled = true;
verificationCheckbox.checked = false;
}
showSuccess('Representative details filled. Please complete the rest of the form.');
}
function determineGovernmentLevel(electedOffice) {
if (!electedOffice) return '';
const office = electedOffice.toLowerCase();
if (office.includes('mp') || office.includes('member of parliament')) {
return 'Federal';
} else if (office.includes('mla') || office.includes('member of the legislative assembly')) {
return 'Provincial';
} else if (office.includes('councillor') || office.includes('councilor') || office.includes('mayor')) {
return 'Municipal';
} else if (office.includes('trustee') || office.includes('school board')) {
return 'School Board';
}
return '';
}
// Load response statistics and campaign details
async function loadResponseStats() {
try {
const response = await fetch(`/api/campaigns/${currentCampaignSlug}/response-stats`);
const data = await response.json();
const data = await window.apiClient.get(`/campaigns/${currentCampaignSlug}/response-stats`);
if (data.success) {
// Store campaign data
currentCampaign = data.campaign;
// Update stats
document.getElementById('stat-total-responses').textContent = data.stats.totalResponses;
document.getElementById('stat-verified').textContent = data.stats.verifiedResponses;
document.getElementById('stat-upvotes').textContent = data.stats.totalUpvotes;
document.getElementById('stats-banner').style.display = 'flex';
// Render campaign header with campaign info
renderCampaignHeader();
}
} catch (error) {
console.error('Error loading stats:', error);
}
}
// Render campaign header with background image and navigation
function renderCampaignHeader() {
console.log('renderCampaignHeader called with campaign:', currentCampaign);
if (!currentCampaign) {
console.warn('No campaign data available');
return;
}
const headerElement = document.querySelector('.response-wall-header');
if (!headerElement) {
console.warn('Header element not found');
return;
}
// Update campaign name subtitle
const campaignNameElement = document.getElementById('campaign-name');
const descriptionElement = document.getElementById('campaign-description');
console.log('Campaign name element:', campaignNameElement);
console.log('Campaign title:', currentCampaign.title);
if (campaignNameElement && currentCampaign.title) {
campaignNameElement.textContent = currentCampaign.title;
campaignNameElement.style.display = 'block';
console.log('Campaign name set to:', currentCampaign.title);
}
if (descriptionElement) {
descriptionElement.textContent = currentCampaign.description || 'See what representatives are saying back to constituents';
}
// Add cover photo if available
if (currentCampaign.cover_photo) {
headerElement.classList.add('has-cover');
headerElement.style.backgroundImage = `url(/uploads/${currentCampaign.cover_photo})`;
} else {
headerElement.classList.remove('has-cover');
headerElement.style.backgroundImage = '';
}
// Set up navigation button listeners
const campaignBtn = document.getElementById('nav-to-campaign');
const homeBtn = document.getElementById('nav-to-home');
if (campaignBtn) {
campaignBtn.addEventListener('click', () => {
window.location.href = `/campaign/${currentCampaign.slug}`;
});
}
if (homeBtn) {
homeBtn.addEventListener('click', () => {
window.location.href = '/';
});
}
// Set up social share buttons
setupShareButtons();
}
// Setup social share buttons
function setupShareButtons() {
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
document.getElementById('share-facebook')?.addEventListener('click', () => {
const url = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`;
window.open(url, '_blank', 'width=600,height=400');
});
// Twitter share
document.getElementById('share-twitter')?.addEventListener('click', () => {
const text = currentCampaign ? `Check out responses from representatives for: ${currentCampaign.title}` : 'Check out representative responses';
const url = `https://twitter.com/intent/tweet?url=${encodeURIComponent(shareUrl)}&text=${encodeURIComponent(text)}`;
window.open(url, '_blank', 'width=600,height=400');
});
// LinkedIn share
document.getElementById('share-linkedin')?.addEventListener('click', () => {
const url = `https://www.linkedin.com/sharing/share-offsite/?url=${encodeURIComponent(shareUrl)}`;
window.open(url, '_blank', 'width=600,height=400');
});
// WhatsApp share
document.getElementById('share-whatsapp')?.addEventListener('click', () => {
const text = currentCampaign ? `Check out responses from representatives for: ${currentCampaign.title}` : 'Check out representative responses';
const url = `https://wa.me/?text=${encodeURIComponent(text + ' ' + shareUrl)}`;
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
document.getElementById('share-email')?.addEventListener('click', () => {
const subject = currentCampaign ? `Representative Responses: ${currentCampaign.title}` : 'Check out these representative responses';
const body = currentCampaign ?
`I thought you might be interested in seeing what representatives are saying about this campaign:\n\n${currentCampaign.title}\n\n${shareUrl}` :
`Check out these representative responses:\n\n${shareUrl}`;
window.location.href = `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
});
// Copy link
document.getElementById('share-copy')?.addEventListener('click', async () => {
const copyBtn = document.getElementById('share-copy');
try {
await navigator.clipboard.writeText(shareUrl);
copyBtn.classList.add('copied');
copyBtn.title = 'Copied!';
setTimeout(() => {
copyBtn.classList.remove('copied');
copyBtn.title = 'Copy Link';
}, 2000);
} catch (err) {
// Fallback for older browsers
const textArea = document.createElement('textarea');
textArea.value = shareUrl;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
document.body.appendChild(textArea);
textArea.select();
try {
document.execCommand('copy');
copyBtn.classList.add('copied');
copyBtn.title = 'Copied!';
setTimeout(() => {
copyBtn.classList.remove('copied');
copyBtn.title = 'Copy Link';
}, 2000);
} catch (err) {
console.error('Failed to copy:', err);
alert('Failed to copy link. Please copy manually: ' + shareUrl);
}
document.body.removeChild(textArea);
}
});
// QR code share
document.getElementById('share-qrcode')?.addEventListener('click', () => {
openQRCodeModal();
});
}
function openQRCodeModal() {
const modal = document.getElementById('qrcode-modal');
const qrcodeImage = document.getElementById('qrcode-image');
const closeBtn = modal.querySelector('.qrcode-close');
const downloadBtn = document.getElementById('download-qrcode-btn');
// Build QR code URL for response wall
const qrcodeUrl = `/api/campaigns/${currentCampaignSlug}/qrcode?type=response-wall`;
qrcodeImage.src = qrcodeUrl;
// Show modal
modal.classList.add('show');
// Close button handler
const closeModal = () => {
modal.classList.remove('show');
};
closeBtn.onclick = closeModal;
// Close when clicking outside the modal content
modal.onclick = (event) => {
if (event.target === modal) {
closeModal();
}
};
// Download button handler
downloadBtn.onclick = async () => {
try {
const response = await fetch(qrcodeUrl);
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${currentCampaignSlug}-response-wall-qrcode.png`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
} catch (error) {
console.error('Failed to download QR code:', error);
alert('Failed to download QR code. Please try again.');
}
};
// Close on Escape key
const handleEscape = (e) => {
if (e.key === 'Escape') {
closeModal();
document.removeEventListener('keydown', handleEscape);
}
};
document.addEventListener('keydown', handleEscape);
}
// Load responses
async function loadResponses(reset = false) {
if (reset) {
@ -110,8 +600,7 @@ async function loadResponses(reset = false) {
limit: LIMIT
});
const response = await fetch(`/api/campaigns/${currentCampaignSlug}/responses?${params}`);
const data = await response.json();
const data = await window.apiClient.get(`/campaigns/${currentCampaignSlug}/responses?${params}`);
showLoading(false);
@ -191,6 +680,18 @@ function createResponseCard(response) {
const upvoteClass = response.hasUpvoted ? 'upvoted' : '';
// Add verify button HTML if response is unverified and has representative email
let verifyButtonHtml = '';
if (!response.is_verified && response.representative_email) {
// Show button if we have a representative email, regardless of whether verification was initially requested
verifyButtonHtml = `
<button class="verify-btn" data-response-id="${response.id}" data-verification-token="${escapeHtml(response.verification_token || '')}" data-rep-email="${escapeHtml(response.representative_email)}">
<span class="verify-icon">📧</span>
<span class="verify-text">Send Verification Email</span>
</button>
`;
}
card.innerHTML = `
<div class="response-header">
<div class="response-rep-info">
@ -220,6 +721,7 @@ function createResponseCard(response) {
<span class="upvote-icon">👍</span>
<span class="upvote-count">${response.upvote_count || 0}</span>
</button>
${verifyButtonHtml}
</div>
</div>
`;
@ -230,6 +732,14 @@ function createResponseCard(response) {
toggleUpvote(response.id, this);
});
// Add event listener for verify button if present
const verifyBtn = card.querySelector('.verify-btn');
if (verifyBtn) {
verifyBtn.addEventListener('click', function() {
handleVerifyClick(response.id, this.dataset.verificationToken, this.dataset.repEmail);
});
}
// Add event listener for screenshot image if present
const screenshotImg = card.querySelector('.screenshot-image');
if (screenshotImg) {
@ -244,17 +754,12 @@ function createResponseCard(response) {
// Toggle upvote
async function toggleUpvote(responseId, button) {
const isUpvoted = button.classList.contains('upvoted');
const url = `/api/responses/${responseId}/upvote`;
const url = `/responses/${responseId}/upvote`;
try {
const response = await fetch(url, {
method: isUpvoted ? 'DELETE' : 'POST',
headers: {
'Content-Type': 'application/json'
}
});
const data = await response.json();
const data = isUpvoted
? await window.apiClient.delete(url)
: await window.apiClient.post(url, {});
if (data.success) {
// Update button state
@ -294,6 +799,19 @@ function openSubmitModal() {
function closeSubmitModal() {
document.getElementById('submit-modal').style.display = 'none';
document.getElementById('submit-response-form').reset();
// Reset postal code lookup
document.getElementById('rep-select-group').style.display = 'none';
document.getElementById('rep-select').innerHTML = '';
loadedRepresentatives = [];
// Reset hidden fields
document.getElementById('representative-email').value = '';
// Reset verification checkbox
const verificationCheckbox = document.getElementById('send-verification');
verificationCheckbox.disabled = false;
verificationCheckbox.checked = false;
}
// Handle response submission
@ -302,16 +820,24 @@ async function handleSubmitResponse(e) {
const formData = new FormData(e.target);
try {
const response = await fetch(`/api/campaigns/${currentCampaignSlug}/responses`, {
method: 'POST',
body: formData
});
// Note: Both send_verification checkbox and representative_email hidden field
// are already included in FormData from the form
// send_verification will be 'on' if checked, undefined if not checked
// representative_email will be populated by handleRepresentativeSelect()
const data = await response.json();
// Get verification status for UI feedback
const sendVerification = document.getElementById('send-verification').checked;
const repEmail = document.getElementById('representative-email').value;
try {
const data = await window.apiClient.postFormData(`/campaigns/${currentCampaignSlug}/responses`, formData);
if (data.success) {
showSuccess(data.message || 'Response submitted successfully! It will appear after moderation.');
let message = data.message || 'Response submitted successfully! It will appear after moderation.';
if (sendVerification && repEmail) {
message += ' A verification email has been sent to the representative.';
}
showSuccess(message);
closeSubmitModal();
// Don't reload responses since submission is pending approval
} else {
@ -323,6 +849,92 @@ async function handleSubmitResponse(e) {
}
}
// Handle verify button click
async function handleVerifyClick(responseId, verificationToken, representativeEmail) {
// Mask email to show only first 3 characters and domain
// e.g., "john.doe@example.com" becomes "joh***@example.com"
const maskEmail = (email) => {
const [localPart, domain] = email.split('@');
if (localPart.length <= 3) {
return `${localPart}***@${domain}`;
}
return `${localPart.substring(0, 3)}***@${domain}`;
};
const maskedEmail = maskEmail(representativeEmail);
// Step 1: Prompt the representative to verify their identity by entering their email
const emailPrompt = prompt(
'To send a verification email, please enter the representative\'s email address.\n\n' +
'This email must match the representative email on file for this response.\n\n' +
`Email on file: ${maskedEmail}`,
''
);
// User cancelled
if (emailPrompt === null) {
return;
}
// Trim and lowercase for comparison
const enteredEmail = emailPrompt.trim().toLowerCase();
const storedEmail = representativeEmail.trim().toLowerCase();
// Check if email is empty
if (!enteredEmail) {
showError('Email address is required to send verification.');
return;
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(enteredEmail)) {
showError('Please enter a valid email address.');
return;
}
// Check if email matches
if (enteredEmail !== storedEmail) {
showError(
'The email you entered does not match the representative email on file.\n\n' +
`Expected: ${representativeEmail}\n` +
`You entered: ${emailPrompt.trim()}\n\n` +
'Verification email cannot be sent.'
);
return;
}
// Step 2: Email matches - confirm sending verification email
const confirmSend = confirm(
'Email verified! Ready to send verification email.\n\n' +
`A verification email will be sent to: ${representativeEmail}\n\n` +
'The representative will receive an email with a link to verify this response as authentic.\n\n' +
'Do you want to send the verification email?'
);
if (!confirmSend) {
return;
}
// Make request to resend verification email
try {
const data = await window.apiClient.post(`/responses/${responseId}/resend-verification`, {});
if (data.success) {
showSuccess(
'Verification email sent successfully!\n\n' +
`An email has been sent to ${representativeEmail} with a verification link.\n\n` +
'The representative must click the link in the email to complete verification.'
);
} else {
showError(data.error || 'Failed to send verification email. Please try again.');
}
} catch (error) {
console.error('Error sending verification email:', error);
showError('An error occurred while sending the verification email. Please try again.');
}
}
// View image in modal/new tab
function viewImage(url) {
window.open(url, '_blank');

View File

@ -8,12 +8,104 @@
<link rel="stylesheet" href="/css/response-wall.css">
</head>
<body>
<div class="container">
<header>
<!-- Campaign Header -->
<div class="response-wall-header">
<div class="response-wall-header-content">
<h1>📢 Community Response Wall</h1>
<p>See what representatives are saying back to constituents</p>
</header>
<h2 id="campaign-name" class="campaign-subtitle" style="display: none;"></h2>
<p id="campaign-description">See what representatives are saying back to constituents</p>
<div class="header-nav-buttons">
<button class="nav-btn" id="nav-to-campaign">
← Back to Campaign
</button>
<button class="nav-btn" id="nav-to-home">
🏠 Home
</button>
</div>
<!-- Social Share Buttons in Header -->
<div class="share-buttons-header">
<!-- Expandable Social Menu -->
<div class="share-socials-container">
<button class="share-btn-primary" id="share-socials-toggle" title="Share on Socials">
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class="share-icon">
<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"/>
</svg>
<span>Socials</span>
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class="chevron-icon">
<path d="M7 10l5 5 5-5z"/>
</svg>
</button>
<!-- Expandable Social Options -->
<div class="share-socials-menu" id="share-socials-menu">
<button class="share-btn-small" id="share-facebook" title="Facebook">
<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>
</button>
<button class="share-btn-small" id="share-twitter" title="Twitter/X">
<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 class="share-btn-small" id="share-linkedin" title="LinkedIn">
<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">
<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>
<span>Copy Link</span>
</button>
<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">
<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>
<span>QR Code</span>
</button>
</div>
</div>
</div>
<div class="container">
<!-- Stats Banner -->
<div class="stats-banner" id="stats-banner" style="display: none;">
<div class="stat-item">
@ -83,9 +175,30 @@
<span class="close" id="modal-close-btn">&times;</span>
<h2>Share a Representative Response</h2>
<form id="submit-response-form" enctype="multipart/form-data">
<!-- Postal Code Lookup -->
<div class="form-group">
<label for="modal-postal-code">Find Your Representative by Postal Code</label>
<div class="postal-lookup-container">
<input type="text" id="modal-postal-code" placeholder="Enter postal code (e.g., T5K 2J1)" maxlength="7">
<button type="button" class="btn btn-secondary" id="lookup-rep-btn">🔍 Search</button>
</div>
<small>Search for representatives by postal code to auto-fill details</small>
</div>
<!-- Representatives Selection (Hidden by default) -->
<div class="form-group" id="rep-select-group" style="display: none;">
<label for="rep-select">Select Representative *</label>
<select id="rep-select" size="5">
<!-- Options will be populated by JavaScript -->
</select>
<small>Click on a representative to auto-fill the form</small>
</div>
<!-- Manual Entry Fields -->
<div class="form-group">
<label for="representative-name">Representative Name *</label>
<input type="text" id="representative-name" name="representative_name" required>
<small>Or enter manually if not found above</small>
</div>
<div class="form-group">
@ -104,6 +217,9 @@
</select>
</div>
<!-- Hidden field to store representative email for verification -->
<input type="hidden" id="representative-email" name="representative_email">
<div class="form-group">
<label for="response-type">Response Type *</label>
<select id="response-type" name="response_type" required>
@ -150,6 +266,14 @@
</label>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="send-verification" name="send_verification">
Send verification request to representative
</label>
<small>This will email the representative to verify this response is authentic</small>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" id="cancel-submit-btn">Cancel</button>
<button type="submit" class="btn btn-primary">Submit Response</button>
@ -158,6 +282,20 @@
</div>
</div>
<!-- QR Code Modal -->
<div id="qrcode-modal" class="qrcode-modal">
<div class="qrcode-modal-content">
<span class="qrcode-close">&times;</span>
<h2>Scan QR Code to Visit Response Wall</h2>
<div class="qrcode-container">
<img id="qrcode-image" src="" alt="Response Wall QR Code">
</div>
<p class="qrcode-instructions">Scan this code with your phone to visit this response wall</p>
<button class="btn btn-secondary" id="download-qrcode-btn">Download QR Code</button>
</div>
</div>
<script src="/js/api-client.js"></script>
<script src="/js/response-wall.js"></script>
</body>
</html>

View File

@ -4,6 +4,7 @@ const { body, param, validationResult } = require('express-validator');
const representativesController = require('../controllers/representatives');
const emailsController = require('../controllers/emails');
const campaignsController = require('../controllers/campaigns');
const customRecipientsController = require('../controllers/customRecipients');
const responsesController = require('../controllers/responses');
const rateLimiter = require('../utils/rate-limiter');
const { requireAdmin, requireAuth, requireNonTemp, optionalAuth } = require('../middleware/auth');
@ -12,6 +13,9 @@ const upload = require('../middleware/upload');
// Import user routes
const userRoutes = require('./users');
// Import Listmonk routes
const listmonkRoutes = require('./listmonk');
// Validation middleware
const handleValidationErrors = (req, res, next) => {
const errors = validationResult(req);
@ -45,6 +49,17 @@ router.post(
representativesController.refreshPostalCode
);
// Geocoding endpoint (proxy to Nominatim)
router.post(
'/geocode',
rateLimiter.general,
[
body('address').notEmpty().withMessage('Address is required')
],
handleValidationErrors,
representativesController.geocodeAddress
);
// Email endpoints
router.post(
'/emails/preview',
@ -163,7 +178,9 @@ router.get('/campaigns/:id/analytics', requireAuth, rateLimiter.general, campaig
// Campaign endpoints (Public)
router.get('/public/campaigns', rateLimiter.general, campaignsController.getPublicCampaigns);
router.get('/public/highlighted-campaign', rateLimiter.general, campaignsController.getHighlightedCampaign);
router.get('/campaigns/:slug', rateLimiter.general, campaignsController.getCampaignBySlug);
router.get('/campaigns/:slug/qrcode', rateLimiter.general, campaignsController.generateQRCode);
router.get('/campaigns/:slug/representatives/:postalCode', rateLimiter.representAPI, campaignsController.getRepresentativesForCampaign);
router.post(
'/campaigns/:slug/track-user',
@ -211,9 +228,66 @@ router.post(
representativesController.trackCall
);
// Custom Recipients Routes
router.get(
'/campaigns/:slug/custom-recipients',
requireNonTemp,
rateLimiter.general,
customRecipientsController.getRecipientsByCampaign
);
router.post(
'/campaigns/:slug/custom-recipients',
requireNonTemp,
rateLimiter.general,
[
body('recipient_name').notEmpty().withMessage('Recipient name is required'),
body('recipient_email').isEmail().withMessage('Valid recipient email is required')
],
handleValidationErrors,
customRecipientsController.createRecipient
);
router.post(
'/campaigns/:slug/custom-recipients/bulk',
requireNonTemp,
rateLimiter.general,
[
body('recipients').isArray().withMessage('Recipients must be an array'),
body('recipients.*.recipient_name').notEmpty().withMessage('Each recipient must have a name'),
body('recipients.*.recipient_email').isEmail().withMessage('Each recipient must have a valid email')
],
handleValidationErrors,
customRecipientsController.bulkCreateRecipients
);
router.put(
'/campaigns/:slug/custom-recipients/:id',
requireNonTemp,
rateLimiter.general,
customRecipientsController.updateRecipient
);
router.delete(
'/campaigns/:slug/custom-recipients/:id',
requireNonTemp,
rateLimiter.general,
customRecipientsController.deleteRecipient
);
router.delete(
'/campaigns/:slug/custom-recipients',
requireNonTemp,
rateLimiter.general,
customRecipientsController.deleteAllRecipients
);
// User management routes (admin only)
router.use('/admin/users', userRoutes);
// Listmonk email sync routes (admin only)
router.use('/listmonk', listmonkRoutes);
// Response Wall Routes
router.get('/campaigns/:slug/responses', rateLimiter.general, responsesController.getCampaignResponses);
router.get('/campaigns/:slug/response-stats', rateLimiter.general, responsesController.getResponseStats);
@ -234,6 +308,11 @@ router.post(
router.post('/responses/:id/upvote', optionalAuth, rateLimiter.general, responsesController.upvoteResponse);
router.delete('/responses/:id/upvote', optionalAuth, rateLimiter.general, responsesController.removeUpvote);
// Response Verification Routes (public - no auth required)
router.get('/responses/:id/verify/:token', responsesController.verifyResponse);
router.get('/responses/:id/report/:token', responsesController.reportResponse);
router.post('/responses/:id/resend-verification', rateLimiter.general, responsesController.resendVerification);
// Admin and Campaign Owner Response Management Routes
router.get('/admin/responses', requireNonTemp, rateLimiter.general, responsesController.getAdminResponses);
router.patch('/admin/responses/:id/status', requireNonTemp, rateLimiter.general,

View File

@ -0,0 +1,26 @@
const express = require('express');
const router = express.Router();
const listmonkController = require('../controllers/listmonkController');
const { requireAdmin } = require('../middleware/auth');
// All Listmonk routes require admin authentication
router.use(requireAdmin);
// Get sync status
router.get('/status', listmonkController.getSyncStatus);
// Get list statistics
router.get('/stats', listmonkController.getListStats);
// Test connection
router.post('/test-connection', listmonkController.testConnection);
// Sync endpoints
router.post('/sync/participants', listmonkController.syncCampaignParticipants);
router.post('/sync/recipients', listmonkController.syncCustomRecipients);
router.post('/sync/all', listmonkController.syncAll);
// Reinitialize lists
router.post('/reinitialize', listmonkController.reinitializeLists);
module.exports = router;

View File

@ -2,9 +2,16 @@ const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const session = require('express-session');
const compression = require('compression');
const cookieParser = require('cookie-parser');
const path = require('path');
require('dotenv').config();
const logger = require('./utils/logger');
const metrics = require('./utils/metrics');
const healthCheck = require('./utils/health-check');
const { conditionalCsrfProtection, getCsrfToken, csrfProtection } = require('./middleware/csrf');
const apiRoutes = require('./routes/api');
const authRoutes = require('./routes/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
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
app.use(helmet({
contentSecurityPolicy: {
@ -30,27 +48,91 @@ app.use(helmet({
}));
// Middleware
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// CORS configuration - Allow credentials for cookie-based CSRF
app.use(cors({
origin: true, // Allow requests from same origin
credentials: true // Allow cookies to be sent
}));
app.use(express.json({ limit: '10mb' }));
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({
secret: process.env.SESSION_SECRET || 'influence-campaign-secret-key-change-in-production',
resave: false,
saveUninitialized: false,
name: 'influence.sid', // Custom session name for security
cookie: {
secure: process.env.NODE_ENV === 'production' && process.env.HTTPS === 'true',
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000 // 24 hours
secure: process.env.NODE_ENV === 'production' && process.env.HTTPS === 'true', // HTTPS only in production
httpOnly: true, // Prevent JavaScript access
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);
// Routes
app.use('/api/auth', authRoutes);
app.use('/api', apiRoutes);
// 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' });
}
});
// Config endpoint - expose APP_URL to client
app.get('/api/config', (req, res) => {
@ -59,6 +141,20 @@ app.get('/api/config', (req, res) => {
});
});
// CSRF token endpoint - Needs CSRF middleware to generate token
app.get('/api/csrf-token', csrfProtection, (req, res) => {
logger.info('CSRF token endpoint hit');
getCsrfToken(req, res);
});
logger.info('CSRF token endpoint registered at /api/csrf-token');
// Auth routes must come before generic API routes
app.use('/api/auth', authRoutes);
// Generic API routes (catches all /api/*)
app.use('/api', apiRoutes);
// Serve the main page
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
@ -100,21 +196,54 @@ app.get('/campaign/:slug', (req, res) => {
// Error handling middleware
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({
logger.error('Application error', {
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!',
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
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' });
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
console.log(`Environment: ${process.env.NODE_ENV}`);
// Graceful shutdown
process.on('SIGTERM', () => {
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;

View File

@ -409,6 +409,62 @@ class EmailService {
throw error;
}
}
/**
* Send response verification email to representative
* @param {Object} options - Email options
* @param {string} options.representativeEmail - Representative's email address
* @param {string} options.representativeName - Representative's name
* @param {string} options.campaignTitle - Campaign title
* @param {string} options.responseType - Type of response (Email, Letter, etc.)
* @param {string} options.responseText - The actual response text
* @param {string} options.submittedDate - Date the response was submitted
* @param {string} options.submitterName - Name of person who submitted
* @param {string} options.verificationUrl - URL to verify the response
* @param {string} options.reportUrl - URL to report as invalid
*/
async sendResponseVerification(options) {
try {
const {
representativeEmail,
representativeName,
campaignTitle,
responseType,
responseText,
submittedDate,
submitterName,
verificationUrl,
reportUrl
} = options;
const templateVariables = {
REPRESENTATIVE_NAME: representativeName,
CAMPAIGN_TITLE: campaignTitle,
RESPONSE_TYPE: responseType,
RESPONSE_TEXT: responseText,
SUBMITTED_DATE: submittedDate,
SUBMITTER_NAME: submitterName || 'Anonymous',
VERIFICATION_URL: verificationUrl,
REPORT_URL: reportUrl,
APP_NAME: process.env.APP_NAME || 'BNKops Influence',
TIMESTAMP: new Date().toLocaleString()
};
const emailOptions = {
to: representativeEmail,
from: {
email: process.env.SMTP_FROM_EMAIL,
name: process.env.SMTP_FROM_NAME
},
subject: `Response Verification Request - ${campaignTitle}`
};
return await this.sendTemplatedEmail('response-verification', templateVariables, emailOptions);
} catch (error) {
console.error('Failed to send response verification email:', error);
throw error;
}
}
}
module.exports = new EmailService();

View File

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

View File

@ -0,0 +1,832 @@
const axios = require('axios');
const logger = require('../utils/logger');
class ListmonkService {
constructor() {
this.baseURL = process.env.LISTMONK_API_URL || 'http://listmonk:9000/api';
this.username = process.env.LISTMONK_USERNAME;
this.password = process.env.LISTMONK_PASSWORD;
this.lists = {
allCampaigns: null,
activeCampaigns: null,
customRecipients: null,
campaignParticipants: null,
emailLogs: null, // For generic email logs (non-campaign)
campaignLists: {} // Dynamic per-campaign lists
};
// Debug logging for environment variables
console.log('🔍 Listmonk Environment Variables (Influence):');
console.log(` LISTMONK_SYNC_ENABLED: ${process.env.LISTMONK_SYNC_ENABLED}`);
console.log(` LISTMONK_INITIAL_SYNC: ${process.env.LISTMONK_INITIAL_SYNC}`);
console.log(` LISTMONK_API_URL: ${process.env.LISTMONK_API_URL}`);
console.log(` LISTMONK_USERNAME: ${this.username ? 'SET' : 'NOT SET'}`);
console.log(` LISTMONK_PASSWORD: ${this.password ? 'SET' : 'NOT SET'}`);
this.syncEnabled = process.env.LISTMONK_SYNC_ENABLED === 'true';
// Additional validation - disable if credentials are missing
if (this.syncEnabled && (!this.username || !this.password)) {
logger.warn('Listmonk credentials missing - disabling sync');
this.syncEnabled = false;
}
console.log(` Final syncEnabled: ${this.syncEnabled}`);
this.lastError = null;
this.lastErrorTime = null;
}
// Validate and clean email address
validateAndCleanEmail(email) {
if (!email || typeof email !== 'string') {
return { valid: false, cleaned: null, error: 'Email is required' };
}
// Trim whitespace and convert to lowercase
let cleaned = email.trim().toLowerCase();
// Basic email format validation
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(cleaned)) {
return { valid: false, cleaned: null, error: 'Invalid email format' };
}
// Check for common typos in domain extensions
const commonTypos = {
'.co': '.ca',
'.cA': '.ca',
'.Ca': '.ca',
'.cOM': '.com',
'.coM': '.com',
'.cOm': '.com',
'.neT': '.net',
'.nEt': '.net',
'.ORg': '.org',
'.oRg': '.org'
};
// Fix common domain extension typos
for (const [typo, correction] of Object.entries(commonTypos)) {
if (cleaned.endsWith(typo)) {
const fixedEmail = cleaned.slice(0, -typo.length) + correction;
logger.warn(`Email validation: Fixed typo in ${email} -> ${fixedEmail}`);
cleaned = fixedEmail;
break;
}
}
// Additional validation: check for suspicious patterns
if (cleaned.includes('..') || cleaned.startsWith('.') || cleaned.endsWith('.')) {
return { valid: false, cleaned: null, error: 'Invalid email pattern' };
}
return { valid: true, cleaned, error: null };
}
// Create axios instance with auth
getClient() {
return axios.create({
baseURL: this.baseURL,
auth: {
username: this.username,
password: this.password
},
headers: {
'Content-Type': 'application/json'
},
timeout: 10000 // 10 second timeout
});
}
// Test connection to Listmonk
async checkConnection() {
if (!this.syncEnabled) {
return false;
}
try {
console.log(`🔍 Testing connection to: ${this.baseURL}`);
console.log(`🔍 Using credentials: ${this.username}:${this.password ? 'SET' : 'NOT SET'}`);
const client = this.getClient();
console.log('🔍 Making request to /health endpoint...');
const { data } = await client.get('/health');
console.log('🔍 Response received:', JSON.stringify(data, null, 2));
if (data.data === true) {
logger.info('Listmonk connection successful');
this.lastError = null;
this.lastErrorTime = null;
return true;
}
console.log('🔍 Health check failed - data.data is not true');
return false;
} catch (error) {
console.log('🔍 Connection error details:', error.message);
if (error.response) {
console.log('🔍 Response status:', error.response.status);
console.log('🔍 Response data:', error.response.data);
}
this.lastError = `Listmonk connection failed: ${error.message}`;
this.lastErrorTime = new Date();
logger.error(this.lastError);
return false;
}
}
// Initialize all lists on startup
async initializeLists() {
if (!this.syncEnabled) {
logger.info('Listmonk sync is disabled');
return false;
}
try {
// Check connection first
const connected = await this.checkConnection();
if (!connected) {
throw new Error(`Cannot connect to Listmonk: ${this.lastError || 'Unknown connection error'}`);
}
// Create main campaign lists
this.lists.allCampaigns = await this.ensureList({
name: 'Influence - All Campaigns',
type: 'private',
optin: 'single',
tags: ['influence', 'campaigns', 'automated'],
description: 'All campaign participants from the influence tool'
});
this.lists.activeCampaigns = await this.ensureList({
name: 'Influence - Active Campaigns',
type: 'private',
optin: 'single',
tags: ['influence', 'active', 'automated'],
description: 'Participants in active campaigns only'
});
this.lists.customRecipients = await this.ensureList({
name: 'Influence - Custom Recipients',
type: 'private',
optin: 'single',
tags: ['influence', 'custom-recipients', 'automated'],
description: 'Custom recipients added to campaigns'
});
this.lists.campaignParticipants = await this.ensureList({
name: 'Influence - Campaign Participants',
type: 'private',
optin: 'single',
tags: ['influence', 'participants', 'automated'],
description: 'Users who have participated in sending campaign emails'
});
this.lists.emailLogs = await this.ensureList({
name: 'Influence - Email Logs',
type: 'private',
optin: 'single',
tags: ['influence', 'email-logs', 'automated'],
description: 'All email activity from the public influence service'
});
logger.info('✅ Listmonk main lists initialized successfully');
// Initialize campaign-specific lists for all campaigns
try {
const nocodbService = require('./nocodb');
const campaigns = await nocodbService.getAllCampaigns();
if (campaigns && campaigns.length > 0) {
logger.info(`🔄 Initializing lists for ${campaigns.length} campaigns...`);
for (const campaign of campaigns) {
const slug = campaign['Campaign Slug'];
const title = campaign['Campaign Title'];
const status = campaign['Status'];
if (slug && title) {
try {
const campaignList = await this.ensureCampaignList(slug, title);
if (campaignList) {
logger.info(`📋 Initialized list for campaign: ${title} (${status})`);
}
} catch (error) {
logger.warn(`Failed to initialize list for campaign ${title}:`, error.message);
}
}
}
logger.info(`✅ Campaign lists initialized: ${Object.keys(this.lists.campaignLists).length} lists`);
}
} catch (error) {
logger.warn('Failed to initialize campaign-specific lists:', error.message);
// Don't fail the entire initialization if campaign lists fail
}
return true;
} catch (error) {
this.lastError = `Failed to initialize Listmonk lists: ${error.message}`;
this.lastErrorTime = new Date();
logger.error(this.lastError);
return false;
}
}
// Ensure a list exists, create if not
async ensureList(listConfig) {
try {
const client = this.getClient();
// First, try to find existing list by name
const { data: listsResponse } = await client.get('/lists');
const existingList = listsResponse.data.results.find(list => list.name === listConfig.name);
if (existingList) {
logger.info(`📋 Found existing list: ${listConfig.name}`);
return existingList;
}
// Create new list
const { data: createResponse } = await client.post('/lists', listConfig);
logger.info(`📋 Created new list: ${listConfig.name}`);
return createResponse.data;
} catch (error) {
logger.error(`Failed to ensure list ${listConfig.name}:`, error.message);
throw error;
}
}
// Ensure a list exists for a specific campaign
async ensureCampaignList(campaignSlug, campaignTitle) {
if (!this.syncEnabled) {
return null;
}
// Check if we already have this campaign list cached
if (this.lists.campaignLists[campaignSlug]) {
return this.lists.campaignLists[campaignSlug];
}
try {
const listConfig = {
name: `Campaign: ${campaignTitle}`,
type: 'private',
optin: 'single',
tags: ['influence', 'campaign', campaignSlug, 'automated'],
description: `Participants who sent emails for the "${campaignTitle}" campaign`
};
const list = await this.ensureList(listConfig);
this.lists.campaignLists[campaignSlug] = list;
logger.info(`✅ Campaign list created/found for: ${campaignTitle}`);
return list;
} catch (error) {
logger.error(`Failed to ensure campaign list for ${campaignSlug}:`, error.message);
return null;
}
}
// Sync a campaign participant to Listmonk
async syncCampaignParticipant(emailData, campaignData) {
// Map NocoDB field names (column titles) to properties
// Try User fields first (new Campaign Emails table), fall back to Sender fields (old table)
const userEmail = emailData['User Email'] || emailData['Sender Email'] || emailData.sender_email;
const userName = emailData['User Name'] || emailData['Sender Name'] || emailData.sender_name;
const userPostalCode = emailData['User Postal Code'] || emailData['Postal Code'] || emailData.postal_code;
const createdAt = emailData['CreatedAt'] || emailData.created_at;
const sentTo = emailData['Sent To'] || emailData.sent_to;
const recipientEmail = emailData['Recipient Email'];
const recipientName = emailData['Recipient Name'];
if (!this.syncEnabled || !userEmail) {
return { success: false, error: 'Sync disabled or no email provided' };
}
// Validate and clean the email address
const emailValidation = this.validateAndCleanEmail(userEmail);
if (!emailValidation.valid) {
logger.warn(`Skipping invalid email: ${userEmail} - ${emailValidation.error}`);
return { success: false, error: emailValidation.error };
}
try {
const subscriberLists = [this.lists.allCampaigns.id, this.lists.campaignParticipants.id];
// Add to active campaigns list if campaign is active
const campaignStatus = campaignData?.Status;
if (campaignStatus === 'active') {
subscriberLists.push(this.lists.activeCampaigns.id);
}
// Add to campaign-specific list
const campaignSlug = campaignData?.['Campaign Slug'];
const campaignTitle = campaignData?.['Campaign Title'];
if (campaignSlug && campaignTitle) {
const campaignList = await this.ensureCampaignList(campaignSlug, campaignTitle);
if (campaignList) {
subscriberLists.push(campaignList.id);
logger.info(`📧 Added ${emailValidation.cleaned} to campaign list: ${campaignTitle}`);
}
}
const subscriberData = {
email: emailValidation.cleaned,
name: userName || emailValidation.cleaned,
status: 'enabled',
lists: subscriberLists,
attribs: {
last_campaign: campaignTitle || 'Unknown',
campaign_slug: campaignSlug || null,
last_sent: createdAt ? new Date(createdAt).toISOString() : new Date().toISOString(),
postal_code: userPostalCode || null,
sent_to_representatives: sentTo || null,
last_recipient_email: recipientEmail || null,
last_recipient_name: recipientName || null
}
};
const result = await this.upsertSubscriber(subscriberData);
return { success: true, subscriberId: result.id };
} catch (error) {
logger.error('Failed to sync campaign participant:', error.message);
return { success: false, error: error.message };
}
}
// Sync a custom recipient to Listmonk
async syncCustomRecipient(recipientData, campaignData) {
// Map NocoDB field names (column titles) to properties
const email = recipientData['Recipient Email'];
const name = recipientData['Recipient Name'];
const title = recipientData['Recipient Title'];
const organization = recipientData['Recipient Organization'];
const phone = recipientData['Recipient Phone'];
const createdAt = recipientData['CreatedAt'];
const campaignId = recipientData['Campaign ID'];
if (!this.syncEnabled || !email) {
return { success: false, error: 'Sync disabled or no email provided' };
}
// Validate and clean the email address
const emailValidation = this.validateAndCleanEmail(email);
if (!emailValidation.valid) {
logger.warn(`Skipping invalid recipient email: ${email} - ${emailValidation.error}`);
return { success: false, error: emailValidation.error };
}
try {
const subscriberLists = [this.lists.customRecipients.id];
// Add to campaign-specific list
const campaignSlug = campaignData?.['Campaign Slug'];
const campaignTitle = campaignData?.['Campaign Title'];
if (campaignSlug && campaignTitle) {
const campaignList = await this.ensureCampaignList(campaignSlug, campaignTitle);
if (campaignList) {
subscriberLists.push(campaignList.id);
logger.info(`📧 Added recipient ${emailValidation.cleaned} to campaign list: ${campaignTitle}`);
}
}
const subscriberData = {
email: emailValidation.cleaned,
name: name || emailValidation.cleaned,
status: 'enabled',
lists: subscriberLists,
attribs: {
campaign: campaignTitle || 'Unknown',
campaign_slug: campaignSlug || null,
title: title || null,
organization: organization || null,
phone: phone || null,
added_date: createdAt ? new Date(createdAt).toISOString() : null,
recipient_type: 'custom'
}
};
const result = await this.upsertSubscriber(subscriberData);
return { success: true, subscriberId: result.id };
} catch (error) {
logger.error('Failed to sync custom recipient:', error.message);
return { success: false, error: error.message };
}
}
// Sync an email log entry to Listmonk (generic public emails)
async syncEmailLog(emailData) {
// Map NocoDB field names for the Email Logs table
const senderEmail = emailData['Sender Email'];
const senderName = emailData['Sender Name'];
const recipientEmail = emailData['Recipient Email'];
const postalCode = emailData['Postal Code'];
const createdAt = emailData['CreatedAt'];
const sentAt = emailData['Sent At'];
if (!this.syncEnabled || !senderEmail) {
return { success: false, error: 'Sync disabled or no email provided' };
}
// Validate and clean the email address
const emailValidation = this.validateAndCleanEmail(senderEmail);
if (!emailValidation.valid) {
logger.warn(`Skipping invalid email log: ${senderEmail} - ${emailValidation.error}`);
return { success: false, error: emailValidation.error };
}
try {
const subscriberData = {
email: emailValidation.cleaned,
name: senderName || emailValidation.cleaned,
status: 'enabled',
lists: [this.lists.emailLogs.id],
attribs: {
last_sent: sentAt ? new Date(sentAt).toISOString() : (createdAt ? new Date(createdAt).toISOString() : new Date().toISOString()),
postal_code: postalCode || null,
last_recipient_email: recipientEmail || null,
source: 'email_logs'
}
};
const result = await this.upsertSubscriber(subscriberData);
return { success: true, subscriberId: result.id };
} catch (error) {
logger.error('Failed to sync email log:', error.message);
return { success: false, error: error.message };
}
}
// Upsert subscriber (create or update)
async upsertSubscriber(subscriberData) {
try {
const client = this.getClient();
// Try to find existing subscriber by email
const { data: searchResponse } = await client.get('/subscribers', {
params: { query: `subscribers.email = '${subscriberData.email}'` }
});
if (searchResponse.data.results && searchResponse.data.results.length > 0) {
// Update existing subscriber
const existingSubscriber = searchResponse.data.results[0];
const subscriberId = existingSubscriber.id;
// Merge lists (don't remove existing ones)
const existingLists = existingSubscriber.lists.map(l => l.id);
const newLists = [...new Set([...existingLists, ...subscriberData.lists])];
// Merge attributes
const mergedAttribs = {
...existingSubscriber.attribs,
...subscriberData.attribs
};
const updateData = {
...subscriberData,
lists: newLists,
attribs: mergedAttribs
};
const { data: updateResponse } = await client.put(`/subscribers/${subscriberId}`, updateData);
logger.info(`Updated subscriber: ${subscriberData.email}`);
return updateResponse.data;
} else {
// Create new subscriber
const { data: createResponse } = await client.post('/subscribers', subscriberData);
logger.info(`Created new subscriber: ${subscriberData.email}`);
return createResponse.data;
}
} catch (error) {
logger.error(`Failed to upsert subscriber ${subscriberData.email}:`, error.message);
throw error;
}
}
// Bulk sync campaign participants
async bulkSyncCampaignParticipants(emails, campaigns) {
if (!this.syncEnabled) {
return { total: 0, success: 0, failed: 0, errors: [] };
}
const results = {
total: emails.length,
success: 0,
failed: 0,
errors: []
};
// Create a map of campaign IDs to campaign data for quick lookup
const campaignMap = {};
if (campaigns && Array.isArray(campaigns)) {
console.log(`🔍 Building campaign map from ${campaigns.length} campaigns`);
campaigns.forEach((campaign, index) => {
// Show keys for first campaign to debug
if (index === 0) {
console.log('🔍 First campaign keys:', Object.keys(campaign));
}
// NocoDB returns 'ID' (all caps) as the system field for record ID
// This is what the 'Campaign ID' link field in emails table references
const id = campaign.ID || campaign.Id || campaign.id;
if (id) {
campaignMap[id] = campaign;
// Also map by slug for fallback lookup
const slug = campaign['Campaign Slug'];
if (slug) {
campaignMap[slug] = campaign;
}
const title = campaign['Campaign Title'];
console.log(`🔍 Mapped campaign ID ${id} and slug ${slug}: ${title}`);
} else {
console.log('⚠️ Campaign has no ID field! Keys:', Object.keys(campaign));
}
});
console.log(`🔍 Campaign map has ${Object.keys(campaignMap).length} entries`);
} else {
console.log('⚠️ No campaigns provided for mapping!');
}
for (const email of emails) {
try {
// Try to find campaign data by Campaign ID (link field) or Campaign Slug (text field)
const campaignId = email['Campaign ID'];
const campaignSlug = email['Campaign Slug'];
const campaignData = campaignId ? campaignMap[campaignId] : (campaignSlug ? campaignMap[campaignSlug] : null);
// Debug first email
if (emails.indexOf(email) === 0) {
console.log('🔍 First email keys:', Object.keys(email));
console.log('🔍 First email Campaign ID field:', email['Campaign ID']);
console.log('🔍 First email Campaign Slug field:', email['Campaign Slug']);
}
if (!campaignData && (campaignId || campaignSlug)) {
console.log(`⚠️ Campaign not found - ID: ${campaignId}, Slug: ${campaignSlug}. Available IDs:`, Object.keys(campaignMap).slice(0, 10));
}
const result = await this.syncCampaignParticipant(email, campaignData);
if (result.success) {
results.success++;
} else {
results.failed++;
const emailAddr = email['Sender Email'] || email.sender_email || 'unknown';
results.errors.push({
email: emailAddr,
error: result.error
});
}
} catch (error) {
results.failed++;
const emailAddr = email['Sender Email'] || email.sender_email || 'unknown';
results.errors.push({
email: emailAddr,
error: error.message
});
}
}
logger.info(`Bulk sync completed: ${results.success} succeeded, ${results.failed} failed`);
return results;
}
// Bulk sync custom recipients
async bulkSyncCustomRecipients(recipients, campaigns) {
if (!this.syncEnabled) {
return { total: 0, success: 0, failed: 0, errors: [] };
}
const results = {
total: recipients.length,
success: 0,
failed: 0,
errors: []
};
// Create a map of campaign IDs to campaign data for quick lookup
const campaignMap = {};
if (campaigns && Array.isArray(campaigns)) {
campaigns.forEach(campaign => {
// NocoDB returns 'ID' (all caps) as the system field for record ID
const id = campaign.ID || campaign.Id || campaign.id;
if (id) {
campaignMap[id] = campaign;
// Also map by slug for fallback lookup
const slug = campaign['Campaign Slug'];
if (slug) {
campaignMap[slug] = campaign;
}
}
});
}
for (const recipient of recipients) {
try {
// Try to find campaign data by Campaign ID or Campaign Slug
const campaignId = recipient['Campaign ID'];
const campaignSlug = recipient['Campaign Slug'];
const campaignData = campaignId ? campaignMap[campaignId] : (campaignSlug ? campaignMap[campaignSlug] : null);
const result = await this.syncCustomRecipient(recipient, campaignData);
if (result.success) {
results.success++;
} else {
results.failed++;
const emailAddr = recipient['Recipient Email'] || 'unknown';
results.errors.push({
email: emailAddr,
error: result.error
});
}
} catch (error) {
results.failed++;
const emailAddr = recipient['Recipient Email'] || 'unknown';
results.errors.push({
email: emailAddr,
error: error.message
});
}
}
logger.info(`Bulk sync custom recipients completed: ${results.success} succeeded, ${results.failed} failed`);
return results;
}
// Bulk sync email logs
async bulkSyncEmailLogs(emailLogs) {
if (!this.syncEnabled) {
return { total: 0, success: 0, failed: 0, errors: [] };
}
const results = {
total: emailLogs.length,
success: 0,
failed: 0,
errors: []
};
for (const emailLog of emailLogs) {
try {
const result = await this.syncEmailLog(emailLog);
if (result.success) {
results.success++;
} else {
results.failed++;
const emailAddr = emailLog['Sender Email'] || 'unknown';
results.errors.push({
email: emailAddr,
error: result.error
});
}
} catch (error) {
results.failed++;
const emailAddr = emailLog['Sender Email'] || 'unknown';
results.errors.push({
email: emailAddr,
error: error.message
});
}
}
logger.info(`Bulk sync email logs completed: ${results.success} succeeded, ${results.failed} failed`);
return results;
}
// Get list statistics
async getListStats() {
if (!this.syncEnabled) {
return null;
}
try {
const client = this.getClient();
const { data: listsResponse } = await client.get('/lists');
const stats = {};
const influenceLists = listsResponse.data.results.filter(list =>
list.tags && list.tags.includes('influence')
);
for (const list of influenceLists) {
stats[list.name] = {
name: list.name,
subscriber_count: list.subscriber_count || 0,
id: list.id
};
}
return stats;
} catch (error) {
logger.error('Failed to get list stats:', error.message);
return null;
}
}
// Get sync status
getSyncStatus() {
return {
enabled: this.syncEnabled,
connected: this.lastError === null,
lastError: this.lastError,
lastErrorTime: this.lastErrorTime,
listsInitialized: Object.values(this.lists).every(list => list !== null)
};
}
}
// Create singleton instance
const listmonkService = new ListmonkService();
// Initialize lists on startup if enabled
if (listmonkService.syncEnabled) {
listmonkService.initializeLists()
.then(async () => {
logger.info('✅ Listmonk service initialized successfully');
// Optional initial sync (only if explicitly enabled)
if (process.env.LISTMONK_INITIAL_SYNC === 'true') {
logger.info('🔄 Performing initial Listmonk sync for influence system...');
// Use setTimeout to delay initial sync to let app fully start
setTimeout(async () => {
try {
const nocodbService = require('./nocodb');
// Sync existing campaign participants
try {
// Use campaignEmails table (not emails) to get proper User Email/Name fields
const emailsData = await nocodbService.getAll(nocodbService.tableIds.campaignEmails);
const emails = emailsData?.list || [];
console.log('🔍 Initial sync - fetched campaign emails:', emails?.length || 0);
if (emails && emails.length > 0) {
console.log('🔍 First email full data:', JSON.stringify(emails[0], null, 2));
}
if (emails && emails.length > 0) {
const campaigns = await nocodbService.getAllCampaigns();
console.log('🔍 Campaigns fetched:', campaigns?.length || 0);
if (campaigns && campaigns.length > 0) {
console.log('🔍 First campaign full data:', JSON.stringify(campaigns[0], null, 2));
}
const emailResults = await listmonkService.bulkSyncCampaignParticipants(emails, campaigns);
logger.info(`📧 Initial campaign participants sync: ${emailResults.success} succeeded, ${emailResults.failed} failed`);
} else {
logger.warn('No campaign participants found for initial sync');
}
} catch (emailError) {
logger.warn('Initial campaign participants sync failed:', {
message: emailError.message,
stack: emailError.stack
});
}
// Sync existing custom recipients
try {
const recipientsData = await nocodbService.getAll(nocodbService.tableIds.customRecipients);
const recipients = recipientsData?.list || [];
console.log('🔍 Initial sync - fetched custom recipients:', recipients?.length || 0);
if (recipients && recipients.length > 0) {
const campaigns = await nocodbService.getAllCampaigns();
const recipientResults = await listmonkService.bulkSyncCustomRecipients(recipients, campaigns);
logger.info(`📋 Initial custom recipients sync: ${recipientResults.success} succeeded, ${recipientResults.failed} failed`);
} else {
logger.warn('No custom recipients found for initial sync');
}
} catch (recipientError) {
logger.warn('Initial custom recipients sync failed:', {
message: recipientError.message,
stack: recipientError.stack
});
}
logger.info('✅ Initial Listmonk sync completed');
} catch (error) {
logger.error('Initial Listmonk sync failed:', {
message: error.message,
stack: error.stack
});
}
}, 5000); // Wait 5 seconds for app to fully start
}
})
.catch(error => {
logger.error('Failed to initialize Listmonk service:', error.message);
});
}
module.exports = listmonkService;

View File

@ -29,7 +29,8 @@ class NocoDBService {
calls: process.env.NOCODB_TABLE_CALLS,
representativeResponses: process.env.NOCODB_TABLE_REPRESENTATIVE_RESPONSES,
responseUpvotes: process.env.NOCODB_TABLE_RESPONSE_UPVOTES,
emailVerifications: process.env.NOCODB_TABLE_EMAIL_VERIFICATIONS
emailVerifications: process.env.NOCODB_TABLE_EMAIL_VERIFICATIONS,
customRecipients: process.env.NOCODB_TABLE_CUSTOM_RECIPIENTS
};
// Validate that all table IDs are set
@ -437,7 +438,9 @@ class NocoDBService {
'Collect User Info': campaignData.collect_user_info,
'Show Email Count': campaignData.show_email_count,
'Allow Email Editing': campaignData.allow_email_editing,
'Allow Custom Recipients': campaignData.allow_custom_recipients,
'Show Response Wall Button': campaignData.show_response_wall,
'Highlight Campaign': campaignData.highlight_campaign,
'Target Government Levels': campaignData.target_government_levels,
'Created By User ID': campaignData.created_by_user_id,
'Created By User Email': campaignData.created_by_user_email,
@ -470,7 +473,9 @@ class NocoDBService {
if (updates.collect_user_info !== undefined) mappedUpdates['Collect User Info'] = updates.collect_user_info;
if (updates.show_email_count !== undefined) mappedUpdates['Show Email Count'] = updates.show_email_count;
if (updates.allow_email_editing !== undefined) mappedUpdates['Allow Email Editing'] = updates.allow_email_editing;
if (updates.allow_custom_recipients !== undefined) mappedUpdates['Allow Custom Recipients'] = updates.allow_custom_recipients;
if (updates.show_response_wall !== undefined) mappedUpdates['Show Response Wall Button'] = updates.show_response_wall;
if (updates.highlight_campaign !== undefined) mappedUpdates['Highlight Campaign'] = updates.highlight_campaign;
if (updates.target_government_levels !== undefined) mappedUpdates['Target Government Levels'] = updates.target_government_levels;
if (updates.updated_at !== undefined) mappedUpdates['UpdatedAt'] = updates.updated_at;
@ -494,6 +499,54 @@ class NocoDBService {
}
}
// Get the currently highlighted campaign
async getHighlightedCampaign() {
try {
const response = await this.getAll(this.tableIds.campaigns, {
where: `(Highlight Campaign,eq,true)`,
limit: 1
});
return response.list && response.list.length > 0 ? response.list[0] : null;
} catch (error) {
console.error('Get highlighted campaign failed:', error);
throw error;
}
}
// Set a campaign as highlighted (and unset all others)
async setHighlightedCampaign(campaignId) {
try {
// First, unset any existing highlighted campaigns
const currentHighlighted = await this.getHighlightedCampaign();
if (currentHighlighted) {
const currentId = currentHighlighted.ID || currentHighlighted.Id || currentHighlighted.id;
if (currentId && currentId !== campaignId) {
await this.updateCampaign(currentId, { highlight_campaign: false });
}
}
// Then set the new highlighted campaign
await this.updateCampaign(campaignId, { highlight_campaign: true });
return { success: true };
} catch (error) {
console.error('Set highlighted campaign failed:', error);
throw error;
}
}
// Unset highlighted campaign
async unsetHighlightedCampaign(campaignId) {
try {
await this.updateCampaign(campaignId, { highlight_campaign: false });
return { success: true };
} catch (error) {
console.error('Unset highlighted campaign failed:', error);
throw error;
}
}
// Campaign email tracking methods
async logCampaignEmail(emailData) {
try {
@ -569,6 +622,25 @@ class NocoDBService {
}
}
async getCampaignVerifiedResponseCount(campaignId) {
try {
if (!this.tableIds.representativeResponses) {
console.warn('Representative responses table not configured, returning 0');
return 0;
}
// Get verified AND approved responses for this campaign
const response = await this.getAll(this.tableIds.representativeResponses, {
where: `(Campaign ID,eq,${campaignId})~and(Is Verified,eq,true)~and(Status,eq,approved)`,
limit: 1000 // Get enough to count
});
return response.pageInfo ? response.pageInfo.totalRows : (response.list ? response.list.length : 0);
} catch (error) {
console.error('Get campaign verified response count failed:', error);
return 0;
}
}
async getCampaignAnalytics(campaignId) {
try {
const response = await this.getAll(this.tableIds.campaignEmails, {
@ -758,6 +830,11 @@ class NocoDBService {
'Is Anonymous': responseData.is_anonymous,
'Status': responseData.status,
'Is Verified': responseData.is_verified,
'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,
'Upvote Count': responseData.upvote_count,
'Submitted IP': responseData.submitted_ip
};
@ -780,6 +857,11 @@ class NocoDBService {
if (updates.upvote_count !== undefined) data['Upvote Count'] = updates.upvote_count;
if (updates.response_text !== undefined) data['Response Text'] = updates.response_text;
if (updates.user_comment !== undefined) data['User Comment'] = updates.user_comment;
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;
console.log(`Updating response ${responseId} with data:`, JSON.stringify(data, null, 2));
@ -858,6 +940,11 @@ class NocoDBService {
is_anonymous: data['Is Anonymous'] || data.is_anonymous || false,
status: data['Status'] || data.status,
is_verified: data['Is Verified'] || data.is_verified || false,
representative_email: data['Representative Email'] || data.representative_email,
verification_token: data['Verification Token'] || data.verification_token,
verification_sent_at: data['Verification Sent At'] || data.verification_sent_at,
verified_at: data['Verified At'] || data.verified_at,
verified_by: data['Verified By'] || data.verified_by,
upvote_count: data['Upvote Count'] || data.upvote_count || 0,
submitted_ip: data['Submitted IP'] || data.submitted_ip,
created_at: data.CreatedAt || data.created_at,
@ -955,6 +1042,212 @@ class NocoDBService {
return { success: false, error: error.message };
}
}
// ===== Custom Recipients Methods =====
/**
* Get all custom recipients for a campaign
*/
async getCustomRecipients(campaignId) {
if (!this.tableIds.customRecipients) {
throw new Error('Custom recipients table not configured');
}
try {
const response = await this.getAll(this.tableIds.customRecipients, {
where: `(Campaign ID,eq,${campaignId})`,
sort: '-CreatedAt',
limit: 1000
});
if (!response.list || response.list.length === 0) {
return [];
}
// Normalize the data structure
return response.list.map(record => ({
id: record.ID || record.Id || record.id,
campaign_id: record['Campaign ID'],
campaign_slug: record['Campaign Slug'],
recipient_name: record['Recipient Name'],
recipient_email: record['Recipient Email'],
recipient_title: record['Recipient Title'] || null,
recipient_organization: record['Recipient Organization'] || null,
notes: record['Notes'] || null,
is_active: record['Is Active'] !== false, // Default to true
created_at: record.CreatedAt,
updated_at: record.UpdatedAt
}));
} catch (error) {
console.error('Error in getCustomRecipients:', error.message);
throw error;
}
}
/**
* Get custom recipients by campaign slug
*/
async getCustomRecipientsBySlug(campaignSlug) {
if (!this.tableIds.customRecipients) {
throw new Error('Custom recipients table not configured');
}
try {
const response = await this.getAll(this.tableIds.customRecipients, {
where: `(Campaign Slug,eq,${campaignSlug})`,
sort: '-CreatedAt',
limit: 1000
});
if (!response.list || response.list.length === 0) {
return [];
}
// Normalize the data structure
return response.list.map(record => ({
id: record.ID || record.Id || record.id,
campaign_id: record['Campaign ID'],
campaign_slug: record['Campaign Slug'],
recipient_name: record['Recipient Name'],
recipient_email: record['Recipient Email'],
recipient_title: record['Recipient Title'] || null,
recipient_organization: record['Recipient Organization'] || null,
notes: record['Notes'] || null,
is_active: record['Is Active'] !== false, // Default to true
created_at: record.CreatedAt,
updated_at: record.UpdatedAt
}));
} catch (error) {
console.error('Error in getCustomRecipientsBySlug:', error.message);
throw error;
}
}
/**
* Create a new custom recipient
*/
async createCustomRecipient(recipientData) {
if (!this.tableIds.customRecipients) {
throw new Error('Custom recipients table not configured');
}
const data = {
'Campaign ID': recipientData.campaign_id,
'Campaign Slug': recipientData.campaign_slug,
'Recipient Name': recipientData.recipient_name,
'Recipient Email': recipientData.recipient_email,
'Recipient Title': recipientData.recipient_title || null,
'Recipient Organization': recipientData.recipient_organization || null,
'Notes': recipientData.notes || null,
'Is Active': recipientData.is_active !== false // Default to true
};
const created = await this.create(this.tableIds.customRecipients, data);
// Return normalized data
return {
id: created.ID || created.Id || created.id,
campaign_id: created['Campaign ID'],
campaign_slug: created['Campaign Slug'],
recipient_name: created['Recipient Name'],
recipient_email: created['Recipient Email'],
recipient_title: created['Recipient Title'] || null,
recipient_organization: created['Recipient Organization'] || null,
notes: created['Notes'] || null,
is_active: created['Is Active'] !== false,
created_at: created.CreatedAt,
updated_at: created.UpdatedAt
};
}
/**
* Update a custom recipient
*/
async updateCustomRecipient(recipientId, updateData) {
if (!this.tableIds.customRecipients) {
throw new Error('Custom recipients table not configured');
}
const data = {};
if (updateData.recipient_name !== undefined) {
data['Recipient Name'] = updateData.recipient_name;
}
if (updateData.recipient_email !== undefined) {
data['Recipient Email'] = updateData.recipient_email;
}
if (updateData.recipient_title !== undefined) {
data['Recipient Title'] = updateData.recipient_title;
}
if (updateData.recipient_organization !== undefined) {
data['Recipient Organization'] = updateData.recipient_organization;
}
if (updateData.notes !== undefined) {
data['Notes'] = updateData.notes;
}
if (updateData.is_active !== undefined) {
data['Is Active'] = updateData.is_active;
}
const updated = await this.update(this.tableIds.customRecipients, recipientId, data);
// Return normalized data
return {
id: updated.ID || updated.Id || updated.id,
campaign_id: updated['Campaign ID'],
campaign_slug: updated['Campaign Slug'],
recipient_name: updated['Recipient Name'],
recipient_email: updated['Recipient Email'],
recipient_title: updated['Recipient Title'] || null,
recipient_organization: updated['Recipient Organization'] || null,
notes: updated['Notes'] || null,
is_active: updated['Is Active'] !== false,
created_at: updated.CreatedAt,
updated_at: updated.UpdatedAt
};
}
/**
* Delete a custom recipient
*/
async deleteCustomRecipient(recipientId) {
if (!this.tableIds.customRecipients) {
throw new Error('Custom recipients table not configured');
}
try {
await this.client.delete(`${this.getTableUrl(this.tableIds.customRecipients)}/${recipientId}`);
return true;
} catch (error) {
if (error.response?.status === 404) {
return false;
}
throw error;
}
}
/**
* Delete all custom recipients for a campaign
*/
async deleteCustomRecipientsByCampaign(campaignId) {
if (!this.tableIds.customRecipients) {
throw new Error('Custom recipients table not configured');
}
try {
const recipients = await this.getCustomRecipients(campaignId);
let deletedCount = 0;
for (const recipient of recipients) {
const deleted = await this.deleteCustomRecipient(recipient.id);
if (deleted) deletedCount++;
}
return deletedCount;
} catch (error) {
console.error('Error deleting custom recipients by campaign:', error.message);
throw error;
}
}
}
module.exports = new NocoDBService();

View File

@ -0,0 +1,152 @@
const QRCode = require('qrcode');
const axios = require('axios');
const FormData = require('form-data');
/**
* QR Code Generation Service
* Generates QR codes for campaign and response wall URLs
*/
/**
* Generate QR code as PNG buffer
* @param {string} text - Text/URL to encode
* @param {Object} options - QR code options
* @returns {Promise<Buffer>} PNG buffer
*/
async function generateQRCode(text, options = {}) {
const defaultOptions = {
type: 'png',
width: 256,
margin: 1,
color: {
dark: '#000000',
light: '#FFFFFF'
},
errorCorrectionLevel: 'M'
};
const qrOptions = { ...defaultOptions, ...options };
try {
const buffer = await QRCode.toBuffer(text, qrOptions);
return buffer;
} catch (error) {
console.error('Failed to generate QR code:', error);
throw new Error('Failed to generate QR code');
}
}
/**
* Upload QR code to NocoDB storage
* @param {Buffer} buffer - PNG buffer
* @param {string} filename - Filename for the upload
* @param {Object} config - NocoDB configuration
* @returns {Promise<Object>} Upload response
*/
async function uploadQRCodeToNocoDB(buffer, filename, config) {
const formData = new FormData();
formData.append('file', buffer, {
filename: filename,
contentType: 'image/png'
});
try {
// Use the base URL without /api/v1 for v2 endpoints
const baseUrl = config.apiUrl.replace('/api/v1', '');
const uploadUrl = `${baseUrl}/api/v2/storage/upload`;
console.log(`Uploading QR code to: ${uploadUrl}`);
const response = await axios({
url: uploadUrl,
method: 'post',
data: formData,
headers: {
...formData.getHeaders(),
'xc-token': config.apiToken
},
params: {
path: 'qrcodes'
}
});
console.log('QR code upload successful:', response.data);
return response.data;
} catch (error) {
console.error('Failed to upload QR code to NocoDB:', error.response?.data || error.message);
throw new Error('Failed to upload QR code');
}
}
/**
* Generate and upload QR code
* @param {string} url - URL to encode
* @param {string} label - Label for the QR code
* @param {Object} config - NocoDB configuration
* @returns {Promise<Object>} Upload result
*/
async function generateAndUploadQRCode(url, label, config) {
if (!url) {
return null;
}
try {
// Generate QR code
const buffer = await generateQRCode(url);
// Create filename
const timestamp = Date.now();
const safeLabel = label.replace(/[^a-z0-9]/gi, '_').toLowerCase();
const filename = `qr_${safeLabel}_${timestamp}.png`;
// Upload to NocoDB
const uploadResult = await uploadQRCodeToNocoDB(buffer, filename, config);
return uploadResult;
} catch (error) {
console.error('Failed to generate and upload QR code:', error);
throw error;
}
}
/**
* Delete QR code from NocoDB storage
* @param {string} fileUrl - File URL to delete
* @param {Object} config - NocoDB configuration
* @returns {Promise<boolean>} Success status
*/
async function deleteQRCodeFromNocoDB(fileUrl, config) {
if (!fileUrl) {
return true;
}
try {
// Extract file path from URL
const urlParts = fileUrl.split('/');
const filePath = urlParts.slice(-2).join('/');
await axios({
url: `${config.apiUrl}/api/v2/storage/upload`,
method: 'delete',
headers: {
'xc-token': config.apiToken
},
params: {
path: filePath
}
});
return true;
} catch (error) {
console.error('Failed to delete QR code from NocoDB:', error);
// Don't throw error for deletion failures
return false;
}
}
module.exports = {
generateQRCode,
uploadQRCodeToNocoDB,
generateAndUploadQRCode,
deleteQRCodeFromNocoDB
};

View File

@ -0,0 +1,155 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Verify Response Submission</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
.container {
background-color: #ffffff;
border-radius: 8px;
padding: 30px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.header {
text-align: center;
padding-bottom: 20px;
border-bottom: 2px solid #3498db;
margin-bottom: 30px;
}
.header h1 {
color: #2c3e50;
margin: 0;
font-size: 24px;
}
.content {
margin-bottom: 30px;
}
.info-box {
background-color: #f8f9fa;
border-left: 4px solid #3498db;
padding: 15px;
margin: 20px 0;
}
.info-box strong {
display: block;
color: #2c3e50;
margin-bottom: 5px;
}
.button-container {
text-align: center;
margin: 30px 0;
}
.button {
display: inline-block;
padding: 12px 30px;
margin: 10px;
text-decoration: none;
border-radius: 5px;
font-weight: bold;
font-size: 16px;
}
.verify-button {
background-color: #27ae60;
color: #ffffff;
}
.verify-button:hover {
background-color: #229954;
}
.report-button {
background-color: #e74c3c;
color: #ffffff;
}
.report-button:hover {
background-color: #c0392b;
}
.response-preview {
background-color: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 15px;
margin: 20px 0;
white-space: pre-wrap;
font-size: 14px;
}
.footer {
text-align: center;
padding-top: 20px;
border-top: 1px solid #dee2e6;
margin-top: 30px;
font-size: 12px;
color: #7f8c8d;
}
.warning {
background-color: #fff3cd;
border: 1px solid #ffc107;
border-radius: 4px;
padding: 15px;
margin: 20px 0;
color: #856404;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>📧 Response Verification Request</h1>
</div>
<div class="content">
<p>Dear {{REPRESENTATIVE_NAME}},</p>
<p>A constituent has submitted a response they claim to have received from you through the <strong>{{APP_NAME}}</strong> platform.</p>
<div class="info-box">
<strong>Campaign:</strong> {{CAMPAIGN_TITLE}}
<br>
<strong>Response Type:</strong> {{RESPONSE_TYPE}}
<br>
<strong>Submitted:</strong> {{SUBMITTED_DATE}}
<br>
<strong>Submitted By:</strong> {{SUBMITTER_NAME}}
</div>
<div class="response-preview">
<strong>Response Content:</strong><br>
{{RESPONSE_TEXT}}
</div>
<div class="warning">
<strong>⚠️ Action Required</strong><br>
Please verify whether this response is authentic by clicking one of the buttons below.
</div>
<div class="button-container">
<a href="{{VERIFICATION_URL}}" class="button verify-button">
✓ Verify This Response
</a>
<a href="{{REPORT_URL}}" class="button report-button">
✗ Report as Invalid
</a>
</div>
<p><strong>Why verify?</strong> Verification helps maintain transparency and accountability in constituent communications. Verified responses appear with a special badge on the Response Wall.</p>
<p><strong>What happens if I report?</strong> Reported responses will be marked as disputed and may be hidden from public view while we investigate.</p>
</div>
<div class="footer">
<p>This email was sent by {{APP_NAME}}<br>
You received this because a constituent submitted a response attributed to you.<br>
Verification links expire in 30 days.</p>
<p><strong>Timestamp:</strong> {{TIMESTAMP}}</p>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,42 @@
RESPONSE VERIFICATION REQUEST
==============================
Dear {{REPRESENTATIVE_NAME}},
A constituent has submitted a response they claim to have received from you through the {{APP_NAME}} platform.
SUBMISSION DETAILS:
-------------------
Campaign: {{CAMPAIGN_TITLE}}
Response Type: {{RESPONSE_TYPE}}
Submitted: {{SUBMITTED_DATE}}
Submitted By: {{SUBMITTER_NAME}}
RESPONSE CONTENT:
-----------------
{{RESPONSE_TEXT}}
ACTION REQUIRED:
---------------
Please verify whether this response is authentic by clicking one of the links below.
VERIFY THIS RESPONSE:
{{VERIFICATION_URL}}
REPORT AS INVALID:
{{REPORT_URL}}
WHY VERIFY?
-----------
Verification helps maintain transparency and accountability in constituent communications. Verified responses appear with a special badge on the Response Wall.
WHAT HAPPENS IF I REPORT?
--------------------------
Reported responses will be marked as disputed and may be hidden from public view while we investigate.
---
This email was sent by {{APP_NAME}}
You received this because a constituent submitted a response attributed to you.
Verification links expire in 30 days.
Timestamp: {{TIMESTAMP}}

View File

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

View File

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

View File

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

View File

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

View File

@ -16,24 +16,127 @@ function cleanupExpiredEntries() {
// Clean up expired entries every minute
setInterval(cleanupExpiredEntries, 60 * 1000);
// General API rate limiter
const general = rateLimit({
// Custom key generator that's safer with trust proxy
const safeKeyGenerator = (req) => {
return req.ip || req.connection?.remoteAddress || 'unknown';
};
// Production-grade rate limiting configuration
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
}
},
// Representative lookup - moderate (30 lookups per minute)
representativeLookup: {
windowMs: 60 * 1000, // 1 minute
max: 30,
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: 100, // limit each IP to 100 requests per windowMs
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: 15 * 60 // 15 minutes in seconds
},
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
// Use a custom key generator that's safer with trust proxy
keyGenerator: (req) => {
// Fallback to connection remote address if req.ip is not available
return req.ip || req.connection?.remoteAddress || 'unknown';
},
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({
windowMs: 60 * 60 * 1000, // 1 hour
max: 10, // limit each IP to 10 emails per hour
@ -43,12 +146,8 @@ const email = rateLimit({
},
standardHeaders: true,
legacyHeaders: false,
skipSuccessfulRequests: false, // Don't skip counting successful requests
// Use a custom key generator that's safer with trust proxy
keyGenerator: (req) => {
// Fallback to connection remote address if req.ip is not available
return req.ip || req.connection?.remoteAddress || 'unknown';
},
skipSuccessfulRequests: false,
keyGenerator: safeKeyGenerator
});
// Custom middleware for per-recipient email rate limiting
@ -90,16 +189,23 @@ const representAPI = rateLimit({
},
standardHeaders: true,
legacyHeaders: false,
// Use a custom key generator that's safer with trust proxy
keyGenerator: (req) => {
// Fallback to connection remote address if req.ip is not available
return req.ip || req.connection?.remoteAddress || 'unknown';
},
keyGenerator: safeKeyGenerator
});
module.exports = {
// Legacy exports (backward compatibility)
general,
email,
representAPI,
// New granular rate limiters
emailSend,
representativeLookup,
login,
publicAPI,
campaignMutation,
perRecipientEmailLimiter,
representAPI
// Export config for testing/monitoring
rateLimitConfig
};

View File

@ -1,7 +1,31 @@
// Validate Canadian postal code format
// Full Canadian postal code validation with proper FSA/LDU rules
function validatePostalCode(postalCode) {
const regex = /^[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d$/;
return regex.test(postalCode);
// Remove whitespace and convert to uppercase
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)
@ -10,10 +34,29 @@ function validateAlbertaPostalCode(postalCode) {
return formatted.startsWith('T') && validatePostalCode(postalCode);
}
// Validate email format
// Validate email format with stricter rules
function validateEmail(email) {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return regex.test(email);
// RFC 5322 simplified email validation
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)
@ -25,16 +68,57 @@ function formatPostalCode(postalCode) {
return cleaned;
}
// Sanitize string input to prevent XSS
// Sanitize string input to prevent XSS and injection attacks
function sanitizeString(str) {
if (typeof str !== 'string') return str;
return str
.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()
.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
function validateRequiredFields(body, requiredFields) {
const errors = [];
@ -130,6 +214,8 @@ module.exports = {
validateEmail,
formatPostalCode,
sanitizeString,
sanitizeHtmlContent,
validateWhereClause,
validateRequiredFields,
containsSuspiciousContent,
generateSlug,

View File

@ -1,25 +1,53 @@
version: '3.8'
services:
app:
build:
context: ./app
dockerfile: Dockerfile
container_name: influence-app
ports:
- "3333:3333"
env_file:
- .env
environment:
- NODE_ENV=production
- REDIS_HOST=redis-changemaker
- REDIS_PORT=6379
volumes:
- ./app:/usr/src/app
- /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
mailhog:
image: mailhog/mailhog:latest
ports:
- "1025:1025" # SMTP server
- "8025:8025" # Web UI
restart: unless-stopped
volumes:
uploads-data:
driver: local
logs-data:
driver: local
networks:
changemaker-lite:
changemakerlite_changemaker-lite:
external: true

View File

@ -9,14 +9,22 @@ NOCODB_API_TOKEN=your_nocodb_api_token_here
NOCODB_PROJECT_ID=your_project_id
# SMTP Configuration
# Configure your email service provider settings
SMTP_HOST=smtp.your-provider.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=your-email@domain.com
SMTP_PASS=your_email_password_or_app_password
SMTP_FROM_EMAIL=your-sender@domain.com
SMTP_FROM_NAME="Your Campaign Name"
# Configure your email service provider settings. See below for development mode smtp
# SMTP_HOST=smtp.your-provider.com
# SMTP_PORT=587
# SMTP_SECURE=false
# SMTP_USER=your-email@domain.com
# SMTP_PASS=your_email_password_or_app_password
# SMTP_FROM_EMAIL=your-sender@domain.com
# SMTP_FROM_NAME="Your Campaign Name"
# Listmonk Configuration (Email List Management)
# Enable to sync campaign participants to Listmonk email lists
LISTMONK_API_URL=http://listmonk_app:9000/api
LISTMONK_USERNAME=API
LISTMONK_PASSWORD=your_listmonk_api_password
LISTMONK_SYNC_ENABLED=false
LISTMONK_INITIAL_SYNC=false
# Admin Configuration
# Set a strong password for admin access
@ -29,7 +37,9 @@ REPRESENT_API_RATE_LIMIT=60
# App Configuration
# Your application URL and basic settings
APP_NAME="BNKops Influence"
APP_URL=http://localhost:3333
BASE_URL=http://localhost:3333
PORT=3333
SESSION_SECRET=generate_a_long_random_string_here_at_least_64_characters_long
NODE_ENV=development
@ -58,22 +68,47 @@ NOCODB_TABLE_REPRESENTATIVE_RESPONSES=
NOCODB_TABLE_RESPONSE_UPVOTES=
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
# Uncomment and modify these for local development with MailHog
# SMTP_HOST=mailhog
# SMTP_PORT=1025
# SMTP_SECURE=false
# SMTP_USER=
# SMTP_PASS=
# SMTP_FROM_EMAIL=dev@albertainfluence.local
# SMTP_FROM_NAME="BNKops Influence Campaign (DEV)"
# Uncomment and modify these for local development with centralized MailHog
# MailHog runs from root docker-compose.yml as a shared service
SMTP_HOST=mailhog-changemaker
SMTP_PORT=1025
SMTP_SECURE=false
SMTP_USER=
SMTP_PASS=
SMTP_FROM_EMAIL=dev@albertainfluence.local
SMTP_FROM_NAME="BNKops Influence Campaign (DEV)"
# Security Notes:
# - Keep your .env file secure and never commit it to version control
# - Use strong, unique passwords for ADMIN_PASSWORD
# - Generate a secure random string for SESSION_SECRET
# - For production, ensure EMAIL_TEST_MODE=false
# - Generate a secure random string for SESSION_SECRET (64+ characters)
# - For production, ensure EMAIL_TEST_MODE=false and HTTPS=true
# - 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:
#
@ -104,3 +139,10 @@ NOCODB_TABLE_EMAIL_VERIFICATIONS=
# SMTP_SECURE=false
# SMTP_USER=apikey
# 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

View File

@ -56,6 +56,27 @@ The application includes a flexible campaign configuration system that allows ad
- Paused: Temporarily disabled
- Archived: Completed campaigns, read-only
**Highlight Campaign** (`highlight_campaign`):
- **Purpose**: Features a single priority campaign on the homepage by replacing the postal code search section
- **Implementation**: Checkbox field in campaigns table (default: false)
- **When Enabled**: Campaign takes over homepage hero section with featured display
- **Exclusivity**: Only ONE campaign can be highlighted at a time
- **Use Cases**:
- ✅ Promote urgent or high-priority campaigns
- ✅ Drive maximum participation for time-sensitive issues
- ✅ Showcase flagship campaigns to new visitors
- ❌ Don't use for evergreen or background campaigns
- **Database**: `highlight_campaign` BOOLEAN field in campaigns table
- **Backend**: `setHighlightedCampaign()` ensures single highlighted campaign by unsetting others
- **Frontend**: `main.js` checks `/public/highlighted-campaign` on page load and replaces UI
- **Admin Panel**:
- Checkbox labeled "⭐ Highlight on Homepage" in create/edit forms
- Gold badge and border on highlighted campaign in campaign list
- Visual indicator: "⭐ Highlighted" badge on campaign card
- **API Endpoints**:
- `GET /public/highlighted-campaign`: Returns current highlighted campaign or null
- Backend validates only one campaign is highlighted via database logic
### Technical Implementation
**Database Schema** (`build-nocodb.sh`):
@ -178,6 +199,12 @@ Business logic layer that handles HTTP requests and responses:
- `getCampaignBySlug()` - Public campaign access for landing pages and participation
- `participateInCampaign()` - Handles user participation, representative lookup, and email sending
- `getCampaignAnalytics()` - Generates campaign performance metrics and participation statistics
- `generateQRCode()` - **NEW** Generates QR codes for campaign and response wall pages using qrcode service
- Supports both `type=campaign` and `type=response-wall` query parameters
- Returns 400x400px PNG images with high error correction (level H)
- Sets proper MIME types and cache headers (1 hour)
- Validates campaign existence before generation
- Uses APP_URL environment variable for absolute URL generation
- Cover photo feature: Supports image uploads (JPEG, PNG, GIF, WebP) up to 5MB, stores files in `/public/uploads/` with unique filenames, and saves filename references to NocoDB
- Advanced features: attachment URL normalization, representative caching, email templating, and analytics tracking
- File upload middleware configuration using multer with diskStorage, file size limits, and image type validation
@ -222,6 +249,11 @@ API endpoint definitions and request validation:
- Campaign management endpoints: CRUD operations for campaigns, participation, analytics
- GET `/api/public/campaigns` - **Public endpoint** (no auth required) returns all active campaigns with email counts if enabled
- GET `/api/campaigns/:slug` - Public campaign lookup by URL slug for campaign landing pages
- GET `/api/campaigns/:slug/qrcode` - **NEW** Generate QR code for campaign or response wall
- Query parameter `type=campaign` (default) generates campaign page QR code
- Query parameter `type=response-wall` generates response wall page QR code
- Returns PNG image with proper MIME type and cache headers
- Used by frontend modal for display and download
- GET `/api/campaigns/:slug/representatives/:postalCode` - Get representatives for a campaign by postal code
- POST `/api/campaigns/:slug/track-user` - Track user participation in campaigns
- POST `/api/campaigns/:slug/send-email` - Send campaign emails to representatives
@ -283,6 +315,18 @@ External system integrations and data access layer:
- Email logging, delivery tracking, and bounce handling
- Development mode with MailHog integration for local testing
- **`qrcode.js`** - QR code generation and management service
- `generateQRCode()` - Generates QR code PNG buffers from URLs or text
- Configurable size, margin, colors, and error correction levels
- Default: 256x256px, 1px margin, black/white, medium error correction
- Used by campaigns controller with 400x400px, high error correction for scanning reliability
- `uploadQRCodeToNocoDB()` - Uploads generated QR codes to NocoDB storage (currently unused in campaign flow)
- `generateAndUploadQRCode()` - Combined generation and upload workflow
- `deleteQRCodeFromNocoDB()` - Cleanup function for removing stored QR codes
- Uses `qrcode` npm package for image generation
- Returns raw PNG buffers for flexible usage (direct HTTP response, storage, etc.)
- Winston logging for debugging and error tracking
### Utilities (`app/utils/`)
Helper functions and shared utilities:
@ -463,7 +507,13 @@ Professional HTML and text email templates with variable substitution:
- Representative lookup integration with postal code processing
- Email composition interface with campaign context and template integration
- Progress tracking through campaign participation workflow
- Social sharing and engagement tracking functionality
- Social sharing functionality with platform-specific handlers for Facebook, Twitter, LinkedIn, WhatsApp, Email, and Copy Link
- `openQRCodeModal()` - **NEW** Opens modal displaying QR code for campaign page
- Loads QR code image from `/api/campaigns/:slug/qrcode?type=campaign`
- Modal with close handlers (X button, outside click, ESC key)
- Download functionality to save QR code as PNG file
- No inline event handlers - all events bound via addEventListener
- Engagement tracking functionality
- **`campaigns-grid.js`** - Public campaigns grid display for homepage
- `CampaignsGrid` class for displaying active campaigns in a responsive card layout
@ -535,6 +585,24 @@ Professional HTML and text email templates with variable substitution:
- Configuration status display and troubleshooting tools
- Real-time UI updates and comprehensive error handling
- **`response-wall.js`** - Response Wall page functionality and community interaction
- Manages the public-facing response wall for campaign-specific representative responses
- `loadResponses()` - Fetches and displays responses with pagination and filtering
- `loadResponseStats()` - Displays aggregate statistics (total, verified, upvotes)
- `openSubmitModal()` - Opens modal for users to submit new representative responses
- `handlePostalLookup()` - Integrates postal code lookup to auto-fill representative details
- `handleSubmitResponse()` - Processes response submission with screenshot upload support
- `renderResponse()` - Creates response cards with upvote buttons, verification badges, and metadata
- Social sharing functionality with platform-specific handlers for Facebook, Twitter, LinkedIn, WhatsApp, Email, and Copy Link
- `openQRCodeModal()` - **NEW** Opens modal displaying QR code for response wall page
- Loads QR code image from `/api/campaigns/:slug/qrcode?type=response-wall`
- Modal with close handlers (X button, outside click, ESC key)
- Download functionality to save QR code as PNG file with descriptive filename
- No inline event handlers - all events bound via addEventListener
- Filtering by government level and sorting by recent/upvotes/verified
- Upvote system with localStorage tracking to prevent duplicate votes
- Campaign integration via URL parameter `?campaign=slug`
- **`login.js`** - Login page functionality and user experience
- Login form handling with client-side validation
- Integration with authentication API and session management

View File

@ -54,7 +54,16 @@ The application supports flexible campaign configuration through the admin panel
- Federal, Provincial, Municipal, School Board
- Filters which representatives are shown
8. **Campaign Status** (`status`) - **Required**
8. **Highlight Campaign** (`highlight_campaign`) - **Default: OFF**
- Displays the campaign prominently on the homepage
- Replaces the postal code search section with campaign information
- Only ONE campaign can be highlighted at a time
- **Database**: Boolean field in campaigns table
- **Backend**: `setHighlightedCampaign()` ensures only one campaign is highlighted
- **Frontend**: `main.js` checks on page load and replaces postal code section
- **Admin Panel**: Shows ⭐ badge on highlighted campaign card
9. **Campaign Status** (`status`) - **Required**
- Draft: Testing only, hidden from public
- Active: Visible on main page
- Paused: Temporarily disabled

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

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

View File

@ -1081,12 +1081,24 @@ create_campaigns_table() {
"uidt": "Checkbox",
"cdf": "false"
},
{
"column_name": "allow_custom_recipients",
"title": "Allow Custom Recipients",
"uidt": "Checkbox",
"cdf": "false"
},
{
"column_name": "show_response_wall",
"title": "Show Response Wall Button",
"uidt": "Checkbox",
"cdf": "false"
},
{
"column_name": "highlight_campaign",
"title": "Highlight Campaign",
"uidt": "Checkbox",
"cdf": "false"
},
{
"column_name": "target_government_levels",
"title": "Target Government Levels",
@ -1467,6 +1479,36 @@ create_representative_responses_table() {
"uidt": "Checkbox",
"cdf": "false"
},
{
"column_name": "representative_email",
"title": "Representative Email",
"uidt": "Email",
"rqd": false
},
{
"column_name": "verification_token",
"title": "Verification Token",
"uidt": "SingleLineText",
"rqd": false
},
{
"column_name": "verification_sent_at",
"title": "Verification Sent At",
"uidt": "DateTime",
"rqd": false
},
{
"column_name": "verified_at",
"title": "Verified At",
"uidt": "DateTime",
"rqd": false
},
{
"column_name": "verified_by",
"title": "Verified By",
"uidt": "SingleLineText",
"rqd": false
},
{
"column_name": "upvote_count",
"title": "Upvote Count",
@ -1615,6 +1657,76 @@ create_users_table() {
create_table "$base_id" "influence_users" "$table_data" "User authentication and management"
}
# Function to create the custom recipients table
create_custom_recipients_table() {
local base_id=$1
local table_data='{
"table_name": "influence_custom_recipients",
"title": "Custom Recipients",
"columns": [
{
"column_name": "id",
"title": "ID",
"uidt": "ID",
"pk": true,
"ai": true,
"rqd": true
},
{
"column_name": "campaign_id",
"title": "Campaign ID",
"uidt": "Number",
"rqd": true
},
{
"column_name": "campaign_slug",
"title": "Campaign Slug",
"uidt": "SingleLineText",
"rqd": true
},
{
"column_name": "recipient_name",
"title": "Recipient Name",
"uidt": "SingleLineText",
"rqd": true
},
{
"column_name": "recipient_email",
"title": "Recipient Email",
"uidt": "Email",
"rqd": true
},
{
"column_name": "recipient_title",
"title": "Recipient Title",
"uidt": "SingleLineText",
"rqd": false
},
{
"column_name": "recipient_organization",
"title": "Recipient Organization",
"uidt": "SingleLineText",
"rqd": false
},
{
"column_name": "notes",
"title": "Notes",
"uidt": "LongText",
"rqd": false
},
{
"column_name": "is_active",
"title": "Is Active",
"uidt": "Checkbox",
"cdf": "true"
}
]
}'
create_table "$base_id" "influence_custom_recipients" "$table_data" "Custom email recipients for campaigns"
}
# Function to create the email verifications table
create_email_verifications_table() {
local base_id=$1
@ -1714,6 +1826,7 @@ update_env_with_table_ids() {
local representative_responses_table_id=$9
local response_upvotes_table_id=${10}
local email_verifications_table_id=${11}
local custom_recipients_table_id=${12}
print_status "Updating .env file with NocoDB project and table IDs..."
@ -1752,6 +1865,7 @@ update_env_with_table_ids() {
update_env_var "NOCODB_TABLE_REPRESENTATIVE_RESPONSES" "$representative_responses_table_id"
update_env_var "NOCODB_TABLE_RESPONSE_UPVOTES" "$response_upvotes_table_id"
update_env_var "NOCODB_TABLE_EMAIL_VERIFICATIONS" "$email_verifications_table_id"
update_env_var "NOCODB_TABLE_CUSTOM_RECIPIENTS" "$custom_recipients_table_id"
print_success "Successfully updated .env file with all table IDs"
@ -1768,6 +1882,7 @@ update_env_with_table_ids() {
print_status "NOCODB_TABLE_CALLS=$call_logs_table_id"
print_status "NOCODB_TABLE_REPRESENTATIVE_RESPONSES=$representative_responses_table_id"
print_status "NOCODB_TABLE_RESPONSE_UPVOTES=$response_upvotes_table_id"
print_status "NOCODB_TABLE_CUSTOM_RECIPIENTS=$custom_recipients_table_id"
}
@ -1896,8 +2011,15 @@ main() {
exit 1
fi
# Create custom recipients table
CUSTOM_RECIPIENTS_TABLE_ID=$(create_custom_recipients_table "$BASE_ID")
if [[ $? -ne 0 ]]; then
print_error "Failed to create custom recipients table"
exit 1
fi
# Validate all table IDs were created successfully
if ! validate_table_ids "$REPRESENTATIVES_TABLE_ID" "$EMAIL_LOGS_TABLE_ID" "$POSTAL_CODES_TABLE_ID" "$CAMPAIGNS_TABLE_ID" "$CAMPAIGN_EMAILS_TABLE_ID" "$USERS_TABLE_ID" "$CALL_LOGS_TABLE_ID" "$REPRESENTATIVE_RESPONSES_TABLE_ID" "$RESPONSE_UPVOTES_TABLE_ID" "$EMAIL_VERIFICATIONS_TABLE_ID"; then
if ! validate_table_ids "$REPRESENTATIVES_TABLE_ID" "$EMAIL_LOGS_TABLE_ID" "$POSTAL_CODES_TABLE_ID" "$CAMPAIGNS_TABLE_ID" "$CAMPAIGN_EMAILS_TABLE_ID" "$USERS_TABLE_ID" "$CALL_LOGS_TABLE_ID" "$REPRESENTATIVE_RESPONSES_TABLE_ID" "$RESPONSE_UPVOTES_TABLE_ID" "$EMAIL_VERIFICATIONS_TABLE_ID" "$CUSTOM_RECIPIENTS_TABLE_ID"; then
print_error "One or more table IDs are invalid"
exit 1
fi
@ -1922,6 +2044,7 @@ main() {
table_mapping["influence_representative_responses"]="$REPRESENTATIVE_RESPONSES_TABLE_ID"
table_mapping["influence_response_upvotes"]="$RESPONSE_UPVOTES_TABLE_ID"
table_mapping["influence_email_verifications"]="$EMAIL_VERIFICATIONS_TABLE_ID"
table_mapping["influence_custom_recipients"]="$CUSTOM_RECIPIENTS_TABLE_ID"
# Get source table information
local source_tables_response
@ -1999,7 +2122,7 @@ main() {
fi
# Update .env file with table IDs
update_env_with_table_ids "$BASE_ID" "$REPRESENTATIVES_TABLE_ID" "$EMAIL_LOGS_TABLE_ID" "$POSTAL_CODES_TABLE_ID" "$CAMPAIGNS_TABLE_ID" "$CAMPAIGN_EMAILS_TABLE_ID" "$USERS_TABLE_ID" "$CALL_LOGS_TABLE_ID" "$REPRESENTATIVE_RESPONSES_TABLE_ID" "$RESPONSE_UPVOTES_TABLE_ID" "$EMAIL_VERIFICATIONS_TABLE_ID"
update_env_with_table_ids "$BASE_ID" "$REPRESENTATIVES_TABLE_ID" "$EMAIL_LOGS_TABLE_ID" "$POSTAL_CODES_TABLE_ID" "$CAMPAIGNS_TABLE_ID" "$CAMPAIGN_EMAILS_TABLE_ID" "$USERS_TABLE_ID" "$CALL_LOGS_TABLE_ID" "$REPRESENTATIVE_RESPONSES_TABLE_ID" "$RESPONSE_UPVOTES_TABLE_ID" "$EMAIL_VERIFICATIONS_TABLE_ID" "$CUSTOM_RECIPIENTS_TABLE_ID"
print_status ""
print_status "============================================================"

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

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

0
mkdocs/.cache/.gitkeep Executable file
View File

View File

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 71 KiB

View File

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

View File

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 65 KiB

View File

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 66 KiB

View File

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

View File

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 65 KiB

View File

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

View File

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 73 KiB

View File

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

View File

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 76 KiB

View File

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

View File

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

View File

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

View File

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 66 KiB

View File

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

View File

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 62 KiB

View File

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

View File

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 72 KiB

View File

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

View File

Before

Width:  |  Height:  |  Size: 66 KiB

After

Width:  |  Height:  |  Size: 66 KiB

View File

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

View File

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 69 KiB

View File

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

Some files were not shown because too many files have changed in this diff Show More