Compare commits
10 Commits
4fb9847812
...
a04b7d5b43
| Author | SHA1 | Date | |
|---|---|---|---|
| a04b7d5b43 | |||
| 84d1285677 | |||
| 4ef4ac414b | |||
| 1bdc2b9ae0 | |||
| 4d8b9effd0 | |||
| e5c32ad25a | |||
| 4b5e2249dd | |||
| 8372b8a4bd | |||
| 91a3f62b93 | |||
| ffb09a01f8 |
10
.gitignore
vendored
@ -4,6 +4,16 @@
|
|||||||
/configs/code-server/.config/*
|
/configs/code-server/.config/*
|
||||||
!/configs/code-server/.config/.gitkeep
|
!/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
|
||||||
.env*
|
.env*
|
||||||
|
|
||||||
|
|||||||
33
README.md
@ -106,4 +106,35 @@ 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.
|
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.
|
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
0
assets/uploads/.gitkeep
Executable file
634
config.sh
@ -82,6 +82,85 @@ backup_env_file() {
|
|||||||
fi
|
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
|
# Function to get all used ports on the system
|
||||||
get_used_ports() {
|
get_used_ports() {
|
||||||
local used_ports=()
|
local used_ports=()
|
||||||
@ -185,6 +264,16 @@ initialize_available_ports() {
|
|||||||
["MAP_PORT"]=3000
|
["MAP_PORT"]=3000
|
||||||
["INFLUENCE_PORT"]=3333
|
["INFLUENCE_PORT"]=3333
|
||||||
["MINI_QR_PORT"]=8089
|
["MINI_QR_PORT"]=8089
|
||||||
|
["REDIS_PORT"]=6379
|
||||||
|
["PROMETHEUS_PORT"]=9090
|
||||||
|
["GRAFANA_PORT"]=3001
|
||||||
|
["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
|
# Find available ports for each service
|
||||||
@ -252,6 +341,24 @@ MAP_PORT=${MAP_PORT:-3000}
|
|||||||
INFLUENCE_PORT=${INFLUENCE_PORT:-3333}
|
INFLUENCE_PORT=${INFLUENCE_PORT:-3333}
|
||||||
MINI_QR_PORT=${MINI_QR_PORT:-8089}
|
MINI_QR_PORT=${MINI_QR_PORT:-8089}
|
||||||
|
|
||||||
|
# Centralized Services Ports
|
||||||
|
REDIS_PORT=${REDIS_PORT:-6379}
|
||||||
|
PROMETHEUS_PORT=${PROMETHEUS_PORT:-9090}
|
||||||
|
GRAFANA_PORT=${GRAFANA_PORT:-3001}
|
||||||
|
|
||||||
|
# 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
|
# Domain Configuration
|
||||||
BASE_DOMAIN=https://changeme.org
|
BASE_DOMAIN=https://changeme.org
|
||||||
DOMAIN=changeme.org
|
DOMAIN=changeme.org
|
||||||
@ -301,6 +408,39 @@ NOCODB_DB_PASSWORD=changeMe
|
|||||||
# Gitea Database Configuration
|
# Gitea Database Configuration
|
||||||
GITEA_DB_PASSWD=changeMe
|
GITEA_DB_PASSWD=changeMe
|
||||||
GITEA_DB_ROOT_PASSWORD=changeMe
|
GITEA_DB_ROOT_PASSWORD=changeMe
|
||||||
|
|
||||||
|
# Centralized Services Configuration
|
||||||
|
# Redis (used by all applications for caching, sessions, queues)
|
||||||
|
REDIS_HOST=redis-changemaker
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=
|
||||||
|
|
||||||
|
# Prometheus (metrics collection)
|
||||||
|
PROMETHEUS_PORT=9090
|
||||||
|
PROMETHEUS_RETENTION_TIME=30d
|
||||||
|
|
||||||
|
# Grafana (monitoring dashboards)
|
||||||
|
GRAFANA_PORT=3001
|
||||||
|
GRAFANA_ADMIN_USER=admin
|
||||||
|
GRAFANA_ADMIN_PASSWORD=changeMe
|
||||||
|
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
|
EOL
|
||||||
|
|
||||||
echo "New .env file created with conflict-free port assignments."
|
echo "New .env file created with conflict-free port assignments."
|
||||||
@ -321,6 +461,22 @@ EOL
|
|||||||
echo "Map: ${MAP_PORT:-3000}"
|
echo "Map: ${MAP_PORT:-3000}"
|
||||||
echo "Influence: ${INFLUENCE_PORT:-3333}"
|
echo "Influence: ${INFLUENCE_PORT:-3333}"
|
||||||
echo "Mini QR: ${MINI_QR_PORT:-8089}"
|
echo "Mini QR: ${MINI_QR_PORT:-8089}"
|
||||||
|
echo ""
|
||||||
|
echo "=== Centralized Services ==="
|
||||||
|
echo "Redis: ${REDIS_PORT:-6379}"
|
||||||
|
echo "Prometheus: ${PROMETHEUS_PORT:-9090}"
|
||||||
|
echo "Grafana: ${GRAFANA_PORT:-3001}"
|
||||||
|
echo ""
|
||||||
|
echo "=== 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 "================================"
|
echo "================================"
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -356,69 +512,143 @@ update_mkdocs_yml() {
|
|||||||
# Function to update service URLs in services.yaml
|
# Function to update service URLs in services.yaml
|
||||||
update_services_yaml() {
|
update_services_yaml() {
|
||||||
local new_domain=$1
|
local new_domain=$1
|
||||||
|
local local_ip=${2:-"localhost"} # Optional parameter for local IP address
|
||||||
|
|
||||||
if [ ! -f "$SERVICES_YAML" ]; then
|
if [ ! -f "$SERVICES_YAML" ]; then
|
||||||
echo "Warning: services.yaml not found at $SERVICES_YAML"
|
echo "Warning: services.yaml not found at $SERVICES_YAML"
|
||||||
return 1
|
return 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Updating service URLs in services.yaml..."
|
echo "Updating service URLs in services.yaml..."
|
||||||
|
|
||||||
# Create a backup of the services.yaml file
|
# Create a backup of the services.yaml file
|
||||||
local timestamp=$(date +"%Y%m%d_%H%M%S")
|
local timestamp=$(date +"%Y%m%d_%H%M%S")
|
||||||
local backup_file="${SERVICES_YAML}.backup_${timestamp}"
|
local backup_file="${SERVICES_YAML}.backup_${timestamp}"
|
||||||
cp "$SERVICES_YAML" "$backup_file"
|
cp "$SERVICES_YAML" "$backup_file"
|
||||||
echo "Created backup of services.yaml at $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
|
# 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"
|
["Code Server"]="code.$new_domain"
|
||||||
["Listmonk"]="listmonk.$new_domain"
|
["Listmonk"]="listmonk.$new_domain"
|
||||||
["NocoDB"]="db.$new_domain"
|
["NocoDB"]="db.$new_domain"
|
||||||
["Map Server"]="map.$new_domain"
|
|
||||||
["Influence"]="influence.$new_domain"
|
|
||||||
["Main Site"]="$new_domain"
|
["Main Site"]="$new_domain"
|
||||||
["MkDocs (Live)"]="docs.$new_domain"
|
["MkDocs (Live)"]="docs.$new_domain"
|
||||||
["Mini QR"]="qr.$new_domain"
|
["Mini QR"]="qr.$new_domain"
|
||||||
|
["MailHog"]="mail.$new_domain"
|
||||||
["n8n"]="n8n.$new_domain"
|
["n8n"]="n8n.$new_domain"
|
||||||
["Gitea"]="git.$new_domain"
|
["Gitea"]="git.$new_domain"
|
||||||
|
["Prometheus"]="prometheus.$new_domain"
|
||||||
|
["Grafana"]="grafana.$new_domain"
|
||||||
|
["Alertmanager"]="alertmanager.$new_domain"
|
||||||
|
["Gotify"]="gotify.$new_domain"
|
||||||
|
["cAdvisor"]="cadvisor.$new_domain"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Process each service mapping
|
# Define service name to local URL mapping for Local tab
|
||||||
for service_name in "${!service_mappings[@]}"; do
|
declare -A local_mappings=(
|
||||||
local target_url="https://${service_mappings[$service_name]}"
|
["Code Server"]="$local_ip:${CODE_SERVER_PORT:-8888}"
|
||||||
|
["NocoDB"]="$local_ip:${NOCODB_PORT:-8090}"
|
||||||
# Use awk to find and update the href for each specific service
|
["Homepage"]="$local_ip:${HOMEPAGE_PORT:-3010}"
|
||||||
# This finds the service by name and updates its href regardless of current URL
|
["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"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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" '
|
awk -v service="$service_name" -v new_url="$target_url" '
|
||||||
BEGIN { in_service = 0 }
|
BEGIN {
|
||||||
|
in_production = 0
|
||||||
# Check if we found the service name
|
in_service = 0
|
||||||
/- [^:]+:/ {
|
}
|
||||||
if ($0 ~ ("- " service ":")) {
|
|
||||||
|
# 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
|
in_service = 1
|
||||||
} else {
|
} else {
|
||||||
in_service = 0
|
in_service = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# If we are in the target service and find href line, update it
|
# 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 "\"")
|
gsub(/href: "[^"]*"/, "href: \"" new_url "\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
# Print the line (modified or not)
|
# Print the line (modified or not)
|
||||||
{ print }
|
{ print }
|
||||||
' "$SERVICES_YAML" > "${SERVICES_YAML}.tmp"
|
' "$SERVICES_YAML" > "${SERVICES_YAML}.tmp"
|
||||||
|
|
||||||
# Replace the original file with the updated version
|
|
||||||
mv "${SERVICES_YAML}.tmp" "$SERVICES_YAML"
|
mv "${SERVICES_YAML}.tmp" "$SERVICES_YAML"
|
||||||
|
echo " ✓ Production: $service_name -> $target_url"
|
||||||
echo " ✓ Updated $service_name -> $target_url"
|
|
||||||
done
|
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
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -537,6 +767,21 @@ ingress:
|
|||||||
- hostname: qr.$new_domain
|
- hostname: qr.$new_domain
|
||||||
service: http://localhost:${MINI_QR_PORT:-8089}
|
service: http://localhost:${MINI_QR_PORT:-8089}
|
||||||
|
|
||||||
|
- hostname: prometheus.$new_domain
|
||||||
|
service: http://localhost:${PROMETHEUS_PORT:-9090}
|
||||||
|
|
||||||
|
- hostname: grafana.$new_domain
|
||||||
|
service: http://localhost:${GRAFANA_PORT:-3001}
|
||||||
|
|
||||||
|
- 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)
|
# Catch-all rule (required)
|
||||||
- service: http_status:404
|
- service: http_status:404
|
||||||
EOL
|
EOL
|
||||||
@ -557,99 +802,6 @@ load_env_vars() {
|
|||||||
fi
|
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
|
# Function to sync ports from root .env to map .env
|
||||||
sync_map_ports() {
|
sync_map_ports() {
|
||||||
echo "Syncing ports from root .env to map configuration..."
|
echo "Syncing ports from root .env to map configuration..."
|
||||||
@ -887,6 +1039,73 @@ EOL
|
|||||||
return 0
|
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
|
# Function to get instance identifier
|
||||||
get_instance_identifier() {
|
get_instance_identifier() {
|
||||||
# Try to get from directory name first
|
# Try to get from directory name first
|
||||||
@ -900,14 +1119,15 @@ get_instance_identifier() {
|
|||||||
default_instance="$dir_name"
|
default_instance="$dir_name"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
# Send informational messages to stderr so they don't get captured
|
||||||
echo "=== Instance Configuration ==="
|
echo "" >&2
|
||||||
echo "To run multiple Changemaker instances on the same machine,"
|
echo "=== Instance Configuration ===" >&2
|
||||||
echo "each instance needs a unique identifier for containers and networks."
|
echo "To run multiple Changemaker instances on the same machine," >&2
|
||||||
echo ""
|
echo "each instance needs a unique identifier for containers and networks." >&2
|
||||||
|
echo "" >&2
|
||||||
|
|
||||||
if [ -n "$default_instance" ]; then
|
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
|
read -p "Use this instance identifier? [Y/n]: " use_detected
|
||||||
if [[ ! "$use_detected" =~ ^[Nn]$ ]]; then
|
if [[ ! "$use_detected" =~ ^[Nn]$ ]]; then
|
||||||
echo "$default_instance"
|
echo "$default_instance"
|
||||||
@ -929,6 +1149,7 @@ get_instance_identifier() {
|
|||||||
instance_id="main"
|
instance_id="main"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Only output the final instance_id to stdout (this gets captured)
|
||||||
echo "$instance_id"
|
echo "$instance_id"
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -941,6 +1162,13 @@ update_docker_compose_names() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
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"
|
echo "Updating docker-compose.yml with instance identifier: $instance_id"
|
||||||
|
|
||||||
# Create a backup of the docker-compose.yml file
|
# Create a backup of the docker-compose.yml file
|
||||||
@ -952,25 +1180,84 @@ update_docker_compose_names() {
|
|||||||
# Create temporary file for modifications
|
# Create temporary file for modifications
|
||||||
local temp_file=$(mktemp)
|
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
|
# 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: \([^-]*\)-changemaker$/container_name: \1-changemaker-${instance_id}/g" \
|
||||||
-e "s/container_name: \([^-]*\)_\([^-]*\)$/container_name: \1_\2_${instance_id}/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: \([^-]*\)_\([^-]*\)_changemaker$/container_name: \1_\2_changemaker_${instance_id}/g" \
|
-e "s/container_name: listmonk_app$/container_name: listmonk_app_${instance_id}_${instance_id}-${instance_id}/g" \
|
||||||
-e "s/networks:/networks:/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:/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/- changemaker-lite$/- changemaker-lite-${instance_id}/g" \
|
||||||
-e "s/driver: bridge$/driver: bridge/g" \
|
-e "s/listmonk-data-[a-zA-Z0-9_-]*:/listmonk-data-${instance_id}:/g" \
|
||||||
-e "s/volumes:/volumes:/g" \
|
|
||||||
-e "s/listmonk-data:/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/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/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/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/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/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"
|
"$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"
|
mv "$temp_file" "$DOCKER_COMPOSE_FILE"
|
||||||
|
|
||||||
echo "✅ Updated docker-compose.yml with instance-specific names:"
|
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
|
# Get instance identifier and update docker-compose.yml
|
||||||
echo -e "\n---- Instance Configuration ----"
|
echo -e "\n---- Instance Configuration ----"
|
||||||
instance_identifier=$(get_instance_identifier)
|
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"
|
update_env_instance_config "$instance_identifier"
|
||||||
|
|
||||||
# Domain configuration
|
# Domain configuration
|
||||||
|
echo -e "\n---- Domain Configuration ----"
|
||||||
read -p "Enter your domain name (without protocol, e.g., example.com): " domain_name
|
read -p "Enter your domain name (without protocol, e.g., example.com): " domain_name
|
||||||
|
|
||||||
if [ -z "$domain_name" ]; then
|
if [ -z "$domain_name" ]; then
|
||||||
@ -1029,6 +1331,20 @@ if [ -z "$domain_name" ]; then
|
|||||||
domain_name="changeme.org"
|
domain_name="changeme.org"
|
||||||
fi
|
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..."
|
echo -e "\nUpdating domain settings in .env file..."
|
||||||
|
|
||||||
# Update main domain settings
|
# 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 "COOKIE_DOMAIN" ".$domain_name"
|
||||||
update_env_var "ALLOWED_ORIGINS" "https://map.$domain_name,http://localhost:3000"
|
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!"
|
echo "Domain settings updated successfully!"
|
||||||
|
|
||||||
# Update the map's .env file
|
# Update the map's .env file
|
||||||
@ -1103,7 +1422,7 @@ update_mkdocs_yml "$domain_name"
|
|||||||
|
|
||||||
# Update service URLs in services.yaml
|
# Update service URLs in services.yaml
|
||||||
echo -e "\nUpdating 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
|
# Update the login URL in main.html
|
||||||
echo -e "\nUpdating 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)
|
gitea_db_root_password=$(generate_password 20)
|
||||||
update_env_var "GITEA_DB_ROOT_PASSWORD" "$gitea_db_root_password"
|
update_env_var "GITEA_DB_ROOT_PASSWORD" "$gitea_db_root_password"
|
||||||
|
|
||||||
|
# Generate and update Grafana admin password
|
||||||
|
grafana_admin_password=$(generate_password 20)
|
||||||
|
update_env_var "GRAFANA_ADMIN_PASSWORD" "$grafana_admin_password"
|
||||||
|
|
||||||
|
# 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."
|
echo "Secure passwords generated and updated."
|
||||||
|
|
||||||
|
# Fix container directory permissions before finishing
|
||||||
|
fix_container_permissions
|
||||||
|
|
||||||
echo -e "\n✅ Configuration completed successfully!"
|
echo -e "\n✅ Configuration completed successfully!"
|
||||||
echo "Your .env file has been configured with:"
|
echo "Your .env file has been configured with:"
|
||||||
echo "- Instance ID: $instance_identifier"
|
echo "- Instance ID: $instance_identifier"
|
||||||
echo "- Domain: $domain_name"
|
echo "- Domain: $domain_name"
|
||||||
|
echo "- Local IP: $local_ip (for Homepage Local tab)"
|
||||||
echo "- Cookie Domain: .$domain_name"
|
echo "- Cookie Domain: .$domain_name"
|
||||||
echo "- Allowed Origins: https://map.$domain_name,http://localhost:3000"
|
echo "- Allowed Origins: https://map.$domain_name,http://localhost:3000"
|
||||||
echo "- Map .env updated with domain settings"
|
echo "- Map .env updated with domain settings"
|
||||||
echo "- Listmonk Admin: $listmonk_user"
|
echo "- Listmonk Admin: $listmonk_user"
|
||||||
echo "- N8N Admin Email: $n8n_email"
|
echo "- N8N Admin Email: $n8n_email"
|
||||||
echo "- Secure random passwords for database, encryption, and NocoDB"
|
echo "- Secure random passwords for database, encryption, and NocoDB"
|
||||||
|
echo "- Grafana Admin Password: Generated (see .env file)"
|
||||||
|
echo "- 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 "- 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 -e "\nYour .env file is located at: $ENV_FILE"
|
||||||
echo "A backup of your original .env file was created before modifications."
|
echo "A backup of your original .env file was created before modifications."
|
||||||
|
|
||||||
@ -1213,6 +1550,29 @@ echo " - Map: http://localhost:${MAP_PORT:-3000}"
|
|||||||
echo " - Influence: http://localhost:${INFLUENCE_PORT:-3333}"
|
echo " - Influence: http://localhost:${INFLUENCE_PORT:-3333}"
|
||||||
echo " - Mini QR: http://localhost:${MINI_QR_PORT:-8089}"
|
echo " - Mini QR: http://localhost:${MINI_QR_PORT:-8089}"
|
||||||
echo ""
|
echo ""
|
||||||
|
echo " Centralized Services:"
|
||||||
|
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 "3. When ready for production:"
|
||||||
echo " ./start-production.sh"
|
echo " ./start-production.sh"
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
100
configs/alertmanager/alertmanager.yml
Normal 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']
|
||||||
@ -1,29 +1,29 @@
|
|||||||
# Cloudflare Tunnel Configuration for cmlite.org
|
# Cloudflare Tunnel Configuration for freealberta.org
|
||||||
# Generated by Changemaker.lite start-production.sh on Sat Jul 5 09:07:25 PM MDT 2025
|
# Generated by Changemaker.lite start-production.sh on Mon Jan 12 01:31:30 PM MST 2026
|
||||||
|
|
||||||
tunnel: 0447884a-8052-41fa-9ff1-f6d16abdc5e1
|
tunnel: 5c56e1b0-5a58-4bf3-96ea-517400ba55f3
|
||||||
credentials-file: /mnt/storagessd1tb/changemaker.lite.dev/changemaker.lite/configs/cloudflare/0447884a-8052-41fa-9ff1-f6d16abdc5e1.json
|
credentials-file: /home/bunker-admin/freealberta/changemaker.lite/configs/cloudflare/5c56e1b0-5a58-4bf3-96ea-517400ba55f3.json
|
||||||
ingress:
|
ingress:
|
||||||
- hostname: homepage.cmlite.org
|
- hostname: homepage.freealberta.org
|
||||||
service: http://localhost:3010
|
service: http://localhost:3011
|
||||||
- hostname: code.cmlite.org
|
- hostname: code.freealberta.org
|
||||||
service: http://localhost:8888
|
service: http://localhost:8889
|
||||||
- hostname: listmonk.cmlite.org
|
- hostname: listmonk.freealberta.org
|
||||||
service: http://localhost:9001
|
service: http://localhost:9001
|
||||||
- hostname: docs.cmlite.org
|
- hostname: docs.freealberta.org
|
||||||
service: http://localhost:4000
|
|
||||||
- hostname: cmlite.org
|
|
||||||
service: http://localhost:4002
|
service: http://localhost:4002
|
||||||
- hostname: n8n.cmlite.org
|
- hostname: freealberta.org
|
||||||
service: http://localhost:5678
|
service: http://localhost:4003
|
||||||
- hostname: db.cmlite.org
|
- hostname: n8n.freealberta.org
|
||||||
service: http://localhost:8090
|
service: http://localhost:5679
|
||||||
- hostname: git.cmlite.org
|
- hostname: db.freealberta.org
|
||||||
service: http://localhost:3030
|
service: http://localhost:8092
|
||||||
- hostname: map.cmlite.org
|
- hostname: git.freealberta.org
|
||||||
service: http://localhost:3000
|
service: http://localhost:3031
|
||||||
- hostname: qr.cmlite.org
|
- hostname: map.freealberta.org
|
||||||
service: http://localhost:8089
|
service: http://localhost:3002
|
||||||
- hostname: influence.cmlite.org
|
- hostname: influence.freealberta.org
|
||||||
service: http://localhost:3333
|
service: http://localhost:3333
|
||||||
|
- hostname: qr.freealberta.org
|
||||||
|
service: http://localhost:8091
|
||||||
- service: http_status:404
|
- service: http_status:404
|
||||||
|
|||||||
0
configs/code-server/.config/.gitkeep
Normal file → Executable file
0
configs/code-server/.local/.gitkeep
Normal file → Executable file
12
configs/grafana/dashboards.yml
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
apiVersion: 1
|
||||||
|
|
||||||
|
providers:
|
||||||
|
- name: 'default'
|
||||||
|
orgId: 1
|
||||||
|
folder: ''
|
||||||
|
type: file
|
||||||
|
disableDeletion: false
|
||||||
|
updateIntervalSeconds: 10
|
||||||
|
allowUiUpdates: true
|
||||||
|
options:
|
||||||
|
path: /etc/grafana/provisioning/dashboards
|
||||||
11
configs/grafana/datasources.yml
Normal file
@ -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
|
||||||
509
configs/grafana/system-health.json
Normal 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
5
configs/homepage/proxmox.yaml
Executable file
@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
# pve:
|
||||||
|
# url: https://proxmox.host.or.ip:8006
|
||||||
|
# token: username@pam!Token ID
|
||||||
|
# secret: secret
|
||||||
@ -1,79 +1,262 @@
|
|||||||
---
|
---
|
||||||
# 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:
|
- Code Server:
|
||||||
icon: mdi-code-braces
|
icon: mdi-code-braces
|
||||||
href: "https://code.cmlite.org"
|
href: "https://code.freealberta.org"
|
||||||
description: VS Code in the browser - Platform Editor
|
description: VS Code in the browser - Platform Editor
|
||||||
container: code-server-changemaker
|
container: code-server-changemaker-freealberta
|
||||||
|
|
||||||
- NocoDB:
|
- NocoDB:
|
||||||
icon: mdi-database
|
icon: mdi-database
|
||||||
href: "https://db.cmlite.org"
|
href: "https://db.freealberta.org"
|
||||||
description: No-code database platform
|
description: No-code database platform
|
||||||
container: changemakerlite-nocodb-1
|
container: changemakerlite-nocodb-1
|
||||||
|
|
||||||
- Map Server:
|
|
||||||
icon: mdi-map
|
|
||||||
href: "https://map.cmlite.org"
|
|
||||||
description: Map server for geospatial data
|
|
||||||
container: nocodb-map-viewer
|
|
||||||
|
|
||||||
- Influence:
|
|
||||||
icon: mdi-account-group
|
|
||||||
href: "https://influence.cmlite.org"
|
|
||||||
description: Political influence and campaign management
|
|
||||||
container: influence-app-1
|
|
||||||
|
|
||||||
|
|
||||||
- Content & Documentation:
|
- Production - Content & Docs:
|
||||||
- Main Site:
|
|
||||||
|
- Main Site:
|
||||||
icon: mdi-web
|
icon: mdi-web
|
||||||
href: "https://cmlite.org"
|
href: "https://freealberta.org"
|
||||||
description: CM-lite campaign website
|
description: CM-lite campaign website
|
||||||
container: mkdocs-site-server-changemaker
|
container: mkdocs-site-server-changemaker-freealberta
|
||||||
|
|
||||||
- MkDocs (Live):
|
- MkDocs (Live):
|
||||||
icon: mdi-book-open-page-variant
|
icon: mdi-book-open-page-variant
|
||||||
href: "https://docs.cmlite.org"
|
href: "https://docs.freealberta.org"
|
||||||
description: Live documentation server with hot reload
|
description: Live documentation server with hot reload
|
||||||
container: mkdocs-changemaker
|
container: mkdocs-changemaker-freealberta
|
||||||
|
|
||||||
- Mini QR:
|
- Mini QR:
|
||||||
icon: mdi-qrcode
|
icon: mdi-qrcode
|
||||||
href: "https://qr.cmlite.org"
|
href: "https://qr.freealberta.org"
|
||||||
description: QR code generator
|
description: QR code generator
|
||||||
container: mini-qr
|
container: mini-qr-freealberta
|
||||||
|
|
||||||
- Listmonk:
|
- Listmonk:
|
||||||
icon: mdi-email-newsletter
|
icon: mdi-email-newsletter
|
||||||
href: "https://listmonk.cmlite.org"
|
href: "https://listmonk.freealberta.org"
|
||||||
description: Newsletter & mailing list manager
|
description: Newsletter & mailing list manager
|
||||||
container: listmonk_app
|
container: listmonk_app_test4_freealberta-freealberta
|
||||||
|
|
||||||
- Automation & Infrastructure:
|
- MailHog:
|
||||||
|
icon: mdi-email-open
|
||||||
|
href: "https://mail.freealberta.org"
|
||||||
|
description: Email testing service
|
||||||
|
container: mailhog-changemaker-freealberta
|
||||||
|
|
||||||
|
- Production - Automation:
|
||||||
|
|
||||||
- n8n:
|
- n8n:
|
||||||
icon: mdi-robot-industrial
|
icon: mdi-robot-industrial
|
||||||
href: "https://n8n.cmlite.org"
|
href: "https://n8n.freealberta.org"
|
||||||
description: Workflow automation platform
|
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):
|
- PostgreSQL (Listmonk):
|
||||||
icon: mdi-database-outline
|
icon: mdi-database-outline
|
||||||
href: "#"
|
href: "#"
|
||||||
description: Database for Listmonk
|
description: Database for Listmonk
|
||||||
container: listmonk_db
|
container: listmonk_db_test4_freealberta-freealberta
|
||||||
|
|
||||||
- PostgreSQL (NocoDB):
|
- PostgreSQL (NocoDB):
|
||||||
icon: mdi-database-outline
|
icon: mdi-database-outline
|
||||||
href: "#"
|
href: "#"
|
||||||
description: Database for NocoDB
|
description: Database for NocoDB
|
||||||
container: changemakerlite-root_db-1
|
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:
|
- Gitea:
|
||||||
icon: mdi-git
|
icon: mdi-git
|
||||||
href: "https://git.cmlite.org"
|
href: "http://100.67.78.53:3030"
|
||||||
description: Git repository hosting
|
description: Git repository hosting (port 3030)
|
||||||
container: gitea_changemaker
|
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
|
||||||
|
|||||||
274
configs/homepage/services.yaml.backup_20260112_125839
Executable 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
|
||||||
@ -18,8 +18,33 @@ cardBlur: xl # xs, md,
|
|||||||
headerStyle: boxed
|
headerStyle: boxed
|
||||||
|
|
||||||
layout:
|
layout:
|
||||||
style: columns
|
# Production Tab Groups - displayed as vertical columns
|
||||||
columns: 3
|
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:
|
docker:
|
||||||
widget:
|
widget:
|
||||||
|
|||||||
0
configs/homepage/widgets.yaml
Normal file → Executable file
235
configs/prometheus/alerts.yml
Normal 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."
|
||||||
61
configs/prometheus/prometheus.yml
Normal 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
|
||||||
@ -8,7 +8,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.code-server
|
dockerfile: Dockerfile.code-server
|
||||||
container_name: code-server-changemaker
|
container_name: code-server-changemaker-freealberta
|
||||||
environment:
|
environment:
|
||||||
- DOCKER_USER=${USER_NAME:-coder}
|
- DOCKER_USER=${USER_NAME:-coder}
|
||||||
- DEFAULT_WORKSPACE=/home/coder/mkdocs/
|
- DEFAULT_WORKSPACE=/home/coder/mkdocs/
|
||||||
@ -21,16 +21,16 @@ services:
|
|||||||
- "${CODE_SERVER_PORT:-8888}:8080"
|
- "${CODE_SERVER_PORT:-8888}:8080"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- changemaker-lite
|
- changemaker-lite-freealberta
|
||||||
|
|
||||||
listmonk-app:
|
listmonk-app:
|
||||||
image: listmonk/listmonk:latest
|
image: listmonk/listmonk:latest
|
||||||
container_name: listmonk_app
|
container_name: listmonk_app_test4_freealberta-freealberta
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "${LISTMONK_PORT:-9001}:9000"
|
- "${LISTMONK_PORT:-9001}:9000"
|
||||||
networks:
|
networks:
|
||||||
- changemaker-lite
|
- changemaker-lite-freealberta
|
||||||
hostname: ${LISTMONK_HOSTNAME}
|
hostname: ${LISTMONK_HOSTNAME}
|
||||||
depends_on:
|
depends_on:
|
||||||
- listmonk-db
|
- listmonk-db
|
||||||
@ -41,7 +41,7 @@ services:
|
|||||||
LISTMONK_db__password: *db-password
|
LISTMONK_db__password: *db-password
|
||||||
LISTMONK_db__database: *db-name
|
LISTMONK_db__database: *db-name
|
||||||
LISTMONK_db__host: listmonk-db
|
LISTMONK_db__host: listmonk-db
|
||||||
LISTMONK_db__port: ${LISTMONK_DB_PORT:-5432}
|
LISTMONK_db__port: 5432
|
||||||
LISTMONK_db__ssl_mode: disable
|
LISTMONK_db__ssl_mode: disable
|
||||||
LISTMONK_db__max_open: ${LISTMONK_DB_MAX_OPEN:-25}
|
LISTMONK_db__max_open: ${LISTMONK_DB_MAX_OPEN:-25}
|
||||||
LISTMONK_db__max_idle: ${LISTMONK_DB_MAX_IDLE:-25}
|
LISTMONK_db__max_idle: ${LISTMONK_DB_MAX_IDLE:-25}
|
||||||
@ -54,12 +54,12 @@ services:
|
|||||||
|
|
||||||
listmonk-db:
|
listmonk-db:
|
||||||
image: postgres:17-alpine
|
image: postgres:17-alpine
|
||||||
container_name: listmonk_db
|
container_name: listmonk_db_test4_freealberta-freealberta
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:${LISTMONK_DB_PORT:-5432}:5432"
|
- "127.0.0.1:${LISTMONK_DB_PORT:-5432}:5432"
|
||||||
networks:
|
networks:
|
||||||
- changemaker-lite
|
- changemaker-lite-freealberta
|
||||||
environment:
|
environment:
|
||||||
<<: *db-credentials
|
<<: *db-credentials
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@ -69,12 +69,12 @@ services:
|
|||||||
retries: 6
|
retries: 6
|
||||||
volumes:
|
volumes:
|
||||||
- type: volume
|
- type: volume
|
||||||
source: listmonk-data
|
source: listmonk-data-freealberta
|
||||||
target: /var/lib/postgresql/data
|
target: /var/lib/postgresql/data
|
||||||
|
|
||||||
mkdocs:
|
mkdocs:
|
||||||
image: squidfunk/mkdocs-material
|
image: squidfunk/mkdocs-material
|
||||||
container_name: mkdocs-changemaker
|
container_name: mkdocs-changemaker-freealberta
|
||||||
volumes:
|
volumes:
|
||||||
- ./mkdocs:/docs:rw
|
- ./mkdocs:/docs:rw
|
||||||
- ./assets/images:/docs/assets/images:rw
|
- ./assets/images:/docs/assets/images:rw
|
||||||
@ -85,12 +85,12 @@ services:
|
|||||||
- SITE_URL=${BASE_DOMAIN:-https://changeme.org}
|
- SITE_URL=${BASE_DOMAIN:-https://changeme.org}
|
||||||
command: serve --dev-addr=0.0.0.0:8000 --watch-theme --livereload
|
command: serve --dev-addr=0.0.0.0:8000 --watch-theme --livereload
|
||||||
networks:
|
networks:
|
||||||
- changemaker-lite
|
- changemaker-lite-freealberta
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
mkdocs-site-server:
|
mkdocs-site-server:
|
||||||
image: lscr.io/linuxserver/nginx:latest
|
image: lscr.io/linuxserver/nginx:latest
|
||||||
container_name: mkdocs-site-server-changemaker
|
container_name: mkdocs-site-server-changemaker-freealberta
|
||||||
environment:
|
environment:
|
||||||
- PUID=${USER_ID:-1000}
|
- PUID=${USER_ID:-1000}
|
||||||
- PGID=${GROUP_ID:-1000}
|
- PGID=${GROUP_ID:-1000}
|
||||||
@ -102,11 +102,11 @@ services:
|
|||||||
- "${MKDOCS_SITE_SERVER_PORT:-4001}:80"
|
- "${MKDOCS_SITE_SERVER_PORT:-4001}:80"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- changemaker-lite
|
- changemaker-lite-freealberta
|
||||||
|
|
||||||
n8n:
|
n8n:
|
||||||
image: docker.n8n.io/n8nio/n8n
|
image: docker.n8n.io/n8nio/n8n
|
||||||
container_name: n8n-changemaker
|
container_name: n8n-changemaker-freealberta
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "${N8N_PORT:-5678}:5678"
|
- "${N8N_PORT:-5678}:5678"
|
||||||
@ -122,10 +122,10 @@ services:
|
|||||||
- N8N_DEFAULT_USER_EMAIL=${N8N_USER_EMAIL:-admin@example.com}
|
- N8N_DEFAULT_USER_EMAIL=${N8N_USER_EMAIL:-admin@example.com}
|
||||||
- N8N_DEFAULT_USER_PASSWORD=${N8N_USER_PASSWORD:-changeMe}
|
- N8N_DEFAULT_USER_PASSWORD=${N8N_USER_PASSWORD:-changeMe}
|
||||||
volumes:
|
volumes:
|
||||||
- n8n_data:/home/node/.n8n
|
- n8n_data_test4:/home/node/.n8n
|
||||||
- ./local-files:/files
|
- ./local-files:/files
|
||||||
networks:
|
networks:
|
||||||
- changemaker-lite
|
- changemaker-lite-freealberta
|
||||||
|
|
||||||
nocodb:
|
nocodb:
|
||||||
depends_on:
|
depends_on:
|
||||||
@ -138,9 +138,9 @@ services:
|
|||||||
- "${NOCODB_PORT:-8090}:8080"
|
- "${NOCODB_PORT:-8090}:8080"
|
||||||
restart: always
|
restart: always
|
||||||
volumes:
|
volumes:
|
||||||
- "nc_data:/usr/app/data"
|
- "nc_data_test4:/usr/app/data"
|
||||||
networks:
|
networks:
|
||||||
- changemaker-lite
|
- changemaker-lite-freealberta
|
||||||
root_db:
|
root_db:
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: root_db
|
POSTGRES_DB: root_db
|
||||||
@ -154,14 +154,14 @@ services:
|
|||||||
image: postgres:16.6
|
image: postgres:16.6
|
||||||
restart: always
|
restart: always
|
||||||
volumes:
|
volumes:
|
||||||
- "db_data:/var/lib/postgresql/data"
|
- "db_data_test4:/var/lib/postgresql/data"
|
||||||
networks:
|
networks:
|
||||||
- changemaker-lite
|
- changemaker-lite-freealberta
|
||||||
|
|
||||||
# Homepage App
|
# Homepage App
|
||||||
homepage-changemaker:
|
homepage-changemaker:
|
||||||
image: ghcr.io/gethomepage/homepage:latest
|
image: ghcr.io/gethomepage/homepage:latest
|
||||||
container_name: homepage-changemaker
|
container_name: homepage-changemaker-freealberta
|
||||||
ports:
|
ports:
|
||||||
- "${HOMEPAGE_PORT:-3010}:3000"
|
- "${HOMEPAGE_PORT:-3010}:3000"
|
||||||
volumes:
|
volumes:
|
||||||
@ -177,12 +177,12 @@ services:
|
|||||||
- HOMEPAGE_VAR_BASE_URL=${HOMEPAGE_VAR_BASE_URL:-http://localhost}
|
- HOMEPAGE_VAR_BASE_URL=${HOMEPAGE_VAR_BASE_URL:-http://localhost}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- changemaker-lite
|
- changemaker-lite-freealberta
|
||||||
|
|
||||||
# Gitea - Git service
|
# Gitea - Git service
|
||||||
gitea-app:
|
gitea-app:
|
||||||
image: gitea/gitea:1.23.7
|
image: gitea/gitea:1.23.7
|
||||||
container_name: gitea_changemaker
|
container_name: gitea_changemaker_test4_freealberta-freealberta
|
||||||
environment:
|
environment:
|
||||||
- USER_UID=${USER_ID:-1000}
|
- USER_UID=${USER_ID:-1000}
|
||||||
- USER_GID=${GROUP_ID:-1000}
|
- USER_GID=${GROUP_ID:-1000}
|
||||||
@ -201,9 +201,9 @@ services:
|
|||||||
- GITEA__server__PROXY_ALLOW_SUBNET=0.0.0.0/0
|
- GITEA__server__PROXY_ALLOW_SUBNET=0.0.0.0/0
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- changemaker-lite
|
- changemaker-lite-freealberta
|
||||||
volumes:
|
volumes:
|
||||||
- gitea_data:/data
|
- gitea_data_test4:/data
|
||||||
- /etc/timezone:/etc/timezone:ro
|
- /etc/timezone:/etc/timezone:ro
|
||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
ports:
|
ports:
|
||||||
@ -214,7 +214,7 @@ services:
|
|||||||
|
|
||||||
gitea-db:
|
gitea-db:
|
||||||
image: mysql:8
|
image: mysql:8
|
||||||
container_name: gitea_mysql_changemaker
|
container_name: gitea_mysql_changemaker_test4_test4_freealberta-freealberta
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- MYSQL_ROOT_PASSWORD=${GITEA_DB_ROOT_PASSWORD}
|
- MYSQL_ROOT_PASSWORD=${GITEA_DB_ROOT_PASSWORD}
|
||||||
@ -222,9 +222,9 @@ services:
|
|||||||
- MYSQL_PASSWORD=${GITEA_DB_PASSWD}
|
- MYSQL_PASSWORD=${GITEA_DB_PASSWD}
|
||||||
- MYSQL_DATABASE=${GITEA_DB_NAME:-gitea}
|
- MYSQL_DATABASE=${GITEA_DB_NAME:-gitea}
|
||||||
networks:
|
networks:
|
||||||
- changemaker-lite
|
- changemaker-lite-freealberta
|
||||||
volumes:
|
volumes:
|
||||||
- mysql_data:/var/lib/mysql
|
- mysql_data_test4:/var/lib/mysql
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "${GITEA_DB_USER:-gitea}", "-p${GITEA_DB_PASSWD}"]
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "${GITEA_DB_USER:-gitea}", "-p${GITEA_DB_PASSWD}"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@ -233,21 +233,210 @@ services:
|
|||||||
|
|
||||||
mini-qr:
|
mini-qr:
|
||||||
image: ghcr.io/lyqht/mini-qr:latest
|
image: ghcr.io/lyqht/mini-qr:latest
|
||||||
container_name: mini-qr
|
container_name: mini-qr-freealberta
|
||||||
ports:
|
ports:
|
||||||
- "${MINI_QR_PORT:-8089}:8080"
|
- "${MINI_QR_PORT:-8089}:8080"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
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:
|
networks:
|
||||||
changemaker-lite:
|
changemaker-lite-freealberta:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
listmonk-data:
|
listmonk-data-freealberta:
|
||||||
n8n_data:
|
n8n_data_test4:
|
||||||
nc_data:
|
nc_data_test4:
|
||||||
db_data:
|
db_data_test4:
|
||||||
gitea_data:
|
gitea_data_test4:
|
||||||
mysql_data:
|
mysql_data_test4:
|
||||||
|
redis-data-freealberta:
|
||||||
|
prometheus-data-freealberta:
|
||||||
|
grafana-data-freealberta:
|
||||||
|
alertmanager-data:
|
||||||
|
gotify-data:
|
||||||
442
docker-compose.yml.backup_20260112_125809
Normal 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
@ -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
|
||||||
161
influence/CSRF_FIX_SUMMARY.md
Normal 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`
|
||||||
243
influence/CUSTOM_RECIPIENTS_IMPLEMENTATION.md
Normal 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
|
||||||
119
influence/DEBUGGING_CUSTOM_RECIPIENTS.md
Normal 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
|
||||||
@ -221,6 +221,7 @@ RATE_LIMIT_MAX_REQUESTS=100
|
|||||||
- **👤 Collect User Info**: Request user name and email
|
- **👤 Collect User Info**: Request user name and email
|
||||||
- **📊 Show Email Count**: Display total emails sent (engagement metric)
|
- **📊 Show Email Count**: Display total emails sent (engagement metric)
|
||||||
- **✏️ Allow Email Editing**: Let users customize email template
|
- **✏️ 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
|
- **🎯 Target Government Levels**: Select Federal, Provincial, Municipal, School Board
|
||||||
|
|
||||||
7. **Set Campaign Status**:
|
7. **Set Campaign Status**:
|
||||||
@ -248,6 +249,37 @@ The homepage automatically displays all active campaigns in a responsive grid be
|
|||||||
- Tablet: 2 columns
|
- Tablet: 2 columns
|
||||||
- Mobile: 1 column
|
- Mobile: 1 column
|
||||||
- **Click Navigation**: Users can click any campaign card to visit the full campaign page
|
- **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
|
- **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
|
- **Security**: HTML content is escaped to prevent XSS attacks
|
||||||
- **Sorting**: Campaigns display newest first by creation date
|
- **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
|
5. Admins can mark verified responses with special badge
|
||||||
6. Community upvotes highlight most impactful responses
|
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
|
### Email Integration
|
||||||
- Modal-based email composer
|
- Modal-based email composer
|
||||||
- Pre-filled recipient information
|
- Pre-filled recipient information
|
||||||
|
|||||||
@ -2,6 +2,9 @@ FROM node:18-alpine
|
|||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
|
# Install curl for healthcheck
|
||||||
|
RUN apk add --no-cache curl
|
||||||
|
|
||||||
# Copy package files
|
# Copy package files
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
const nocoDB = require('../services/nocodb');
|
const nocoDB = require('../services/nocodb');
|
||||||
const emailService = require('../services/email');
|
const emailService = require('../services/email');
|
||||||
const representAPI = require('../services/represent-api');
|
const representAPI = require('../services/represent-api');
|
||||||
|
const qrcodeService = require('../services/qrcode');
|
||||||
const { generateSlug, validateSlug } = require('../utils/validators');
|
const { generateSlug, validateSlug } = require('../utils/validators');
|
||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
@ -33,7 +34,7 @@ const upload = multer({
|
|||||||
const allowedTypes = /jpeg|jpg|png|gif|webp/;
|
const allowedTypes = /jpeg|jpg|png|gif|webp/;
|
||||||
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
|
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
|
||||||
const mimetype = allowedTypes.test(file.mimetype);
|
const mimetype = allowedTypes.test(file.mimetype);
|
||||||
|
|
||||||
if (mimetype && extname) {
|
if (mimetype && extname) {
|
||||||
return cb(null, true);
|
return cb(null, true);
|
||||||
} else {
|
} else {
|
||||||
@ -74,10 +75,10 @@ async function cacheRepresentatives(postalCode, representatives, representData)
|
|||||||
city: representData.city,
|
city: representData.city,
|
||||||
province: representData.province
|
province: representData.province
|
||||||
});
|
});
|
||||||
|
|
||||||
// Cache representatives using the existing method
|
// Cache representatives using the existing method
|
||||||
const result = await nocoDB.storeRepresentatives(postalCode, representatives);
|
const result = await nocoDB.storeRepresentatives(postalCode, representatives);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
console.log(`Successfully cached ${result.count} representatives for ${postalCode}`);
|
console.log(`Successfully cached ${result.count} representatives for ${postalCode}`);
|
||||||
} else {
|
} else {
|
||||||
@ -94,7 +95,7 @@ class CampaignsController {
|
|||||||
async getPublicCampaigns(req, res, next) {
|
async getPublicCampaigns(req, res, next) {
|
||||||
try {
|
try {
|
||||||
const campaigns = await nocoDB.getAllCampaigns();
|
const campaigns = await nocoDB.getAllCampaigns();
|
||||||
|
|
||||||
// Filter to only active campaigns and normalize data structure
|
// Filter to only active campaigns and normalize data structure
|
||||||
const activeCampaigns = await Promise.all(
|
const activeCampaigns = await Promise.all(
|
||||||
campaigns
|
campaigns
|
||||||
@ -104,7 +105,7 @@ class CampaignsController {
|
|||||||
})
|
})
|
||||||
.map(async (campaign) => {
|
.map(async (campaign) => {
|
||||||
const id = campaign.ID || campaign.Id || campaign.id;
|
const id = campaign.ID || campaign.Id || campaign.id;
|
||||||
|
|
||||||
// Debug: Log specific fields we're looking for
|
// Debug: Log specific fields we're looking for
|
||||||
console.log(`Campaign ${id}:`, {
|
console.log(`Campaign ${id}:`, {
|
||||||
'Show Call Count': campaign['Show Call Count'],
|
'Show Call Count': campaign['Show Call Count'],
|
||||||
@ -112,7 +113,7 @@ class CampaignsController {
|
|||||||
'Show Email Count': campaign['Show Email Count'],
|
'Show Email Count': campaign['Show Email Count'],
|
||||||
'show_email_count': campaign.show_email_count
|
'show_email_count': campaign.show_email_count
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get email count if show_email_count is enabled
|
// Get email count if show_email_count is enabled
|
||||||
let emailCount = null;
|
let emailCount = null;
|
||||||
const showEmailCount = campaign['Show Email Count'] || campaign.show_email_count;
|
const showEmailCount = campaign['Show Email Count'] || campaign.show_email_count;
|
||||||
@ -131,9 +132,16 @@ class CampaignsController {
|
|||||||
console.log(`Call count result: ${callCount}`);
|
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 rawTargetLevels = campaign['Target Government Levels'] || campaign.target_government_levels;
|
||||||
const normalizedTargetLevels = normalizeTargetLevels(rawTargetLevels);
|
const normalizedTargetLevels = normalizeTargetLevels(rawTargetLevels);
|
||||||
|
|
||||||
// Return only public-facing information
|
// Return only public-facing information
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@ -148,7 +156,8 @@ class CampaignsController {
|
|||||||
target_government_levels: normalizedTargetLevels,
|
target_government_levels: normalizedTargetLevels,
|
||||||
created_at: campaign.CreatedAt || campaign.created_at,
|
created_at: campaign.CreatedAt || campaign.created_at,
|
||||||
emailCount,
|
emailCount,
|
||||||
callCount
|
callCount,
|
||||||
|
verifiedResponseCount
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@ -167,11 +176,80 @@ 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)
|
// Get all campaigns (for admin panel)
|
||||||
async getAllCampaigns(req, res, next) {
|
async getAllCampaigns(req, res, next) {
|
||||||
try {
|
try {
|
||||||
const campaigns = await nocoDB.getAllCampaigns();
|
const campaigns = await nocoDB.getAllCampaigns();
|
||||||
|
|
||||||
// Get email counts for each campaign and normalize data structure
|
// Get email counts for each campaign and normalize data structure
|
||||||
const campaignsWithCounts = await Promise.all(campaigns.map(async (campaign) => {
|
const campaignsWithCounts = await Promise.all(campaigns.map(async (campaign) => {
|
||||||
const id = campaign.ID || campaign.Id || campaign.id;
|
const id = campaign.ID || campaign.Id || campaign.id;
|
||||||
@ -182,7 +260,7 @@ class CampaignsController {
|
|||||||
|
|
||||||
const rawTargetLevels = campaign['Target Government Levels'] || campaign.target_government_levels;
|
const rawTargetLevels = campaign['Target Government Levels'] || campaign.target_government_levels;
|
||||||
const normalizedTargetLevels = normalizeTargetLevels(rawTargetLevels);
|
const normalizedTargetLevels = normalizeTargetLevels(rawTargetLevels);
|
||||||
|
|
||||||
// Normalize campaign data structure for frontend
|
// Normalize campaign data structure for frontend
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
@ -199,7 +277,9 @@ class CampaignsController {
|
|||||||
collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info,
|
collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info,
|
||||||
show_email_count: campaign['Show Email Count'] || campaign.show_email_count,
|
show_email_count: campaign['Show Email Count'] || campaign.show_email_count,
|
||||||
allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing,
|
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,
|
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,
|
target_government_levels: normalizedTargetLevels,
|
||||||
created_at: campaign.CreatedAt || campaign.created_at,
|
created_at: campaign.CreatedAt || campaign.created_at,
|
||||||
updated_at: campaign.UpdatedAt || campaign.updated_at,
|
updated_at: campaign.UpdatedAt || campaign.updated_at,
|
||||||
@ -247,7 +327,7 @@ class CampaignsController {
|
|||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
const campaign = await nocoDB.getCampaignById(id);
|
const campaign = await nocoDB.getCampaignById(id);
|
||||||
|
|
||||||
if (!campaign) {
|
if (!campaign) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
success: false,
|
success: false,
|
||||||
@ -258,12 +338,12 @@ class CampaignsController {
|
|||||||
// Debug logging
|
// Debug logging
|
||||||
console.log('Campaign object keys:', Object.keys(campaign));
|
console.log('Campaign object keys:', Object.keys(campaign));
|
||||||
console.log('Campaign ID field:', campaign.ID, campaign.Id, campaign.id);
|
console.log('Campaign ID field:', campaign.ID, campaign.Id, campaign.id);
|
||||||
|
|
||||||
const normalizedId = campaign.ID || campaign.Id || campaign.id;
|
const normalizedId = campaign.ID || campaign.Id || campaign.id;
|
||||||
console.log('Using normalized ID:', normalizedId);
|
console.log('Using normalized ID:', normalizedId);
|
||||||
|
|
||||||
const emailCount = await nocoDB.getCampaignEmailCount(normalizedId);
|
const emailCount = await nocoDB.getCampaignEmailCount(normalizedId);
|
||||||
|
|
||||||
// Normalize campaign data structure for frontend
|
// Normalize campaign data structure for frontend
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@ -282,6 +362,7 @@ class CampaignsController {
|
|||||||
collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info,
|
collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info,
|
||||||
show_email_count: campaign['Show Email Count'] || campaign.show_email_count,
|
show_email_count: campaign['Show Email Count'] || campaign.show_email_count,
|
||||||
allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing,
|
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,
|
show_response_wall: campaign['Show Response Wall Button'] || campaign.show_response_wall,
|
||||||
target_government_levels: normalizeTargetLevels(campaign['Target Government Levels'] || campaign.target_government_levels),
|
target_government_levels: normalizeTargetLevels(campaign['Target Government Levels'] || campaign.target_government_levels),
|
||||||
created_at: campaign.CreatedAt || campaign.created_at,
|
created_at: campaign.CreatedAt || campaign.created_at,
|
||||||
@ -308,7 +389,7 @@ class CampaignsController {
|
|||||||
try {
|
try {
|
||||||
const { slug } = req.params;
|
const { slug } = req.params;
|
||||||
const campaign = await nocoDB.getCampaignBySlug(slug);
|
const campaign = await nocoDB.getCampaignBySlug(slug);
|
||||||
|
|
||||||
if (!campaign) {
|
if (!campaign) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
success: false,
|
success: false,
|
||||||
@ -316,8 +397,8 @@ class CampaignsController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const campaignStatus = normalizeStatus(campaign['Status'] || campaign.status);
|
const campaignStatus = normalizeStatus(campaign['Status'] || campaign.status);
|
||||||
if (campaignStatus !== 'active') {
|
if (campaignStatus !== 'active') {
|
||||||
return res.status(403).json({
|
return res.status(403).json({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Campaign is not currently active'
|
error: 'Campaign is not currently active'
|
||||||
@ -369,6 +450,7 @@ class CampaignsController {
|
|||||||
show_email_count: campaign['Show Email Count'] || campaign.show_email_count,
|
show_email_count: campaign['Show Email Count'] || campaign.show_email_count,
|
||||||
show_call_count: campaign['Show Call Count'] || campaign.show_call_count,
|
show_call_count: campaign['Show Call Count'] || campaign.show_call_count,
|
||||||
allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing,
|
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,
|
show_response_wall: campaign['Show Response Wall Button'] || campaign.show_response_wall,
|
||||||
target_government_levels: normalizeTargetLevels(campaign['Target Government Levels'] || campaign.target_government_levels),
|
target_government_levels: normalizeTargetLevels(campaign['Target Government Levels'] || campaign.target_government_levels),
|
||||||
emailCount,
|
emailCount,
|
||||||
@ -389,6 +471,22 @@ class CampaignsController {
|
|||||||
// Create new campaign
|
// Create new campaign
|
||||||
async createCampaign(req, res, next) {
|
async createCampaign(req, res, next) {
|
||||||
try {
|
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 {
|
const {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
@ -401,6 +499,7 @@ class CampaignsController {
|
|||||||
collect_user_info = true,
|
collect_user_info = true,
|
||||||
show_email_count = true,
|
show_email_count = true,
|
||||||
allow_email_editing = false,
|
allow_email_editing = false,
|
||||||
|
allow_custom_recipients = false,
|
||||||
target_government_levels = ['Federal', 'Provincial', 'Municipal']
|
target_government_levels = ['Federal', 'Provincial', 'Municipal']
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
@ -409,7 +508,7 @@ class CampaignsController {
|
|||||||
const ownerName = req.user?.name ?? req.session?.userName ?? null;
|
const ownerName = req.user?.name ?? req.session?.userName ?? null;
|
||||||
const normalizedStatus = normalizeStatus(status, 'draft');
|
const normalizedStatus = normalizeStatus(status, 'draft');
|
||||||
let slug = generateSlug(title);
|
let slug = generateSlug(title);
|
||||||
|
|
||||||
// Ensure slug is unique
|
// Ensure slug is unique
|
||||||
let counter = 1;
|
let counter = 1;
|
||||||
let originalSlug = slug;
|
let originalSlug = slug;
|
||||||
@ -431,6 +530,7 @@ class CampaignsController {
|
|||||||
collect_user_info,
|
collect_user_info,
|
||||||
show_email_count,
|
show_email_count,
|
||||||
allow_email_editing,
|
allow_email_editing,
|
||||||
|
allow_custom_recipients,
|
||||||
// NocoDB MultiSelect expects an array of values
|
// NocoDB MultiSelect expects an array of values
|
||||||
target_government_levels: normalizeTargetLevels(target_government_levels),
|
target_government_levels: normalizeTargetLevels(target_government_levels),
|
||||||
// Add user ownership data
|
// Add user ownership data
|
||||||
@ -461,6 +561,7 @@ class CampaignsController {
|
|||||||
collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info,
|
collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info,
|
||||||
show_email_count: campaign['Show Email Count'] || campaign.show_email_count,
|
show_email_count: campaign['Show Email Count'] || campaign.show_email_count,
|
||||||
allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing,
|
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),
|
target_government_levels: normalizeTargetLevels(campaign['Target Government Levels'] || campaign.target_government_levels),
|
||||||
created_at: campaign.CreatedAt || campaign.created_at,
|
created_at: campaign.CreatedAt || campaign.created_at,
|
||||||
updated_at: campaign.UpdatedAt || campaign.updated_at,
|
updated_at: campaign.UpdatedAt || campaign.updated_at,
|
||||||
@ -547,7 +648,40 @@ class CampaignsController {
|
|||||||
} else {
|
} else {
|
||||||
console.log('No cover photo file in request');
|
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);
|
console.log('Updates object before saving:', updates);
|
||||||
|
|
||||||
if (updates.status !== undefined) {
|
if (updates.status !== undefined) {
|
||||||
@ -577,7 +711,7 @@ class CampaignsController {
|
|||||||
if (oldSlug && newSlug && oldSlug !== newSlug) {
|
if (oldSlug && newSlug && oldSlug !== newSlug) {
|
||||||
console.log(`Campaign slug changed from '${oldSlug}' to '${newSlug}', updating references...`);
|
console.log(`Campaign slug changed from '${oldSlug}' to '${newSlug}', updating references...`);
|
||||||
const cascadeResult = await nocoDB.updateCampaignSlugReferences(id, oldSlug, newSlug);
|
const cascadeResult = await nocoDB.updateCampaignSlugReferences(id, oldSlug, newSlug);
|
||||||
|
|
||||||
if (cascadeResult.success) {
|
if (cascadeResult.success) {
|
||||||
console.log(`Successfully updated slug references: ${cascadeResult.updatedCampaignEmails} campaign emails, ${cascadeResult.updatedCallLogs} call logs`);
|
console.log(`Successfully updated slug references: ${cascadeResult.updatedCampaignEmails} campaign emails, ${cascadeResult.updatedCallLogs} call logs`);
|
||||||
} else {
|
} else {
|
||||||
@ -603,6 +737,7 @@ class CampaignsController {
|
|||||||
collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info,
|
collect_user_info: campaign['Collect User Info'] || campaign.collect_user_info,
|
||||||
show_email_count: campaign['Show Email Count'] || campaign.show_email_count,
|
show_email_count: campaign['Show Email Count'] || campaign.show_email_count,
|
||||||
allow_email_editing: campaign['Allow Email Editing'] || campaign.allow_email_editing,
|
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),
|
target_government_levels: normalizeTargetLevels(campaign['Target Government Levels'] || campaign.target_government_levels),
|
||||||
created_at: campaign.CreatedAt || campaign.created_at,
|
created_at: campaign.CreatedAt || campaign.created_at,
|
||||||
updated_at: campaign.UpdatedAt || campaign.updated_at,
|
updated_at: campaign.UpdatedAt || campaign.updated_at,
|
||||||
@ -626,7 +761,7 @@ class CampaignsController {
|
|||||||
async deleteCampaign(req, res, next) {
|
async deleteCampaign(req, res, next) {
|
||||||
try {
|
try {
|
||||||
const { id } = req.params;
|
const { id } = req.params;
|
||||||
|
|
||||||
await nocoDB.deleteCampaign(id);
|
await nocoDB.deleteCampaign(id);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
@ -693,13 +828,13 @@ class CampaignsController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use custom email content if provided (when email editing is enabled), otherwise use campaign defaults
|
// Use custom email content if provided (when email editing is enabled), otherwise use campaign defaults
|
||||||
const allowEmailEditing = campaign['Allow Email Editing'] || campaign.allow_email_editing;
|
const allowEmailEditing = campaign['Allow Email Editing'] || campaign.allow_email_editing;
|
||||||
const subject = (allowEmailEditing && customEmailSubject)
|
const subject = (allowEmailEditing && customEmailSubject)
|
||||||
? customEmailSubject
|
? customEmailSubject
|
||||||
: (campaign['Email Subject'] || campaign.email_subject);
|
: (campaign['Email Subject'] || campaign.email_subject);
|
||||||
const message = (allowEmailEditing && customEmailBody)
|
const message = (allowEmailEditing && customEmailBody)
|
||||||
? customEmailBody
|
? customEmailBody
|
||||||
: (campaign['Email Body'] || campaign.email_body);
|
: (campaign['Email Body'] || campaign.email_body);
|
||||||
|
|
||||||
let emailResult = { success: true };
|
let emailResult = { success: true };
|
||||||
@ -832,7 +967,7 @@ class CampaignsController {
|
|||||||
async getRepresentativesForCampaign(req, res, next) {
|
async getRepresentativesForCampaign(req, res, next) {
|
||||||
try {
|
try {
|
||||||
const { slug, postalCode } = req.params;
|
const { slug, postalCode } = req.params;
|
||||||
|
|
||||||
// Get campaign to check target levels
|
// Get campaign to check target levels
|
||||||
const campaign = await nocoDB.getCampaignBySlug(slug);
|
const campaign = await nocoDB.getCampaignBySlug(slug);
|
||||||
if (!campaign) {
|
if (!campaign) {
|
||||||
@ -854,13 +989,13 @@ class CampaignsController {
|
|||||||
const formattedPostalCode = postalCode.replace(/\s/g, '').toUpperCase();
|
const formattedPostalCode = postalCode.replace(/\s/g, '').toUpperCase();
|
||||||
let representatives = [];
|
let representatives = [];
|
||||||
let result = null;
|
let result = null;
|
||||||
|
|
||||||
// Try to check cached data first, but don't fail if NocoDB is down
|
// Try to check cached data first, but don't fail if NocoDB is down
|
||||||
let cachedData = [];
|
let cachedData = [];
|
||||||
try {
|
try {
|
||||||
cachedData = await nocoDB.getRepresentativesByPostalCode(formattedPostalCode);
|
cachedData = await nocoDB.getRepresentativesByPostalCode(formattedPostalCode);
|
||||||
console.log(`Cache check for ${formattedPostalCode}: found ${cachedData.length} records`);
|
console.log(`Cache check for ${formattedPostalCode}: found ${cachedData.length} records`);
|
||||||
|
|
||||||
if (cachedData && cachedData.length > 0) {
|
if (cachedData && cachedData.length > 0) {
|
||||||
representatives = cachedData;
|
representatives = cachedData;
|
||||||
console.log(`Using cached representatives for ${formattedPostalCode}`);
|
console.log(`Using cached representatives for ${formattedPostalCode}`);
|
||||||
@ -873,13 +1008,13 @@ class CampaignsController {
|
|||||||
if (representatives.length === 0) {
|
if (representatives.length === 0) {
|
||||||
console.log(`Fetching representatives from Represent API for ${formattedPostalCode}`);
|
console.log(`Fetching representatives from Represent API for ${formattedPostalCode}`);
|
||||||
result = await representAPI.getRepresentativesByPostalCode(postalCode);
|
result = await representAPI.getRepresentativesByPostalCode(postalCode);
|
||||||
|
|
||||||
// Process representatives from both concordance and centroid
|
// Process representatives from both concordance and centroid
|
||||||
// Add concordance representatives (if any)
|
// Add concordance representatives (if any)
|
||||||
if (result.representatives_concordance && result.representatives_concordance.length > 0) {
|
if (result.representatives_concordance && result.representatives_concordance.length > 0) {
|
||||||
representatives = representatives.concat(result.representatives_concordance);
|
representatives = representatives.concat(result.representatives_concordance);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add centroid representatives (if any) - these are the actual elected officials
|
// Add centroid representatives (if any) - these are the actual elected officials
|
||||||
if (result.representatives_centroid && result.representatives_centroid.length > 0) {
|
if (result.representatives_centroid && result.representatives_centroid.length > 0) {
|
||||||
representatives = representatives.concat(result.representatives_centroid);
|
representatives = representatives.concat(result.representatives_centroid);
|
||||||
@ -914,10 +1049,10 @@ class CampaignsController {
|
|||||||
|
|
||||||
const filteredRepresentatives = representatives.filter(rep => {
|
const filteredRepresentatives = representatives.filter(rep => {
|
||||||
const repLevel = rep.elected_office ? rep.elected_office.toLowerCase() : 'other';
|
const repLevel = rep.elected_office ? rep.elected_office.toLowerCase() : 'other';
|
||||||
|
|
||||||
return targetLevels.some(targetLevel => {
|
return targetLevels.some(targetLevel => {
|
||||||
const target = targetLevel.toLowerCase();
|
const target = targetLevel.toLowerCase();
|
||||||
|
|
||||||
if (target === 'federal' && (repLevel.includes('mp') || repLevel.includes('member of parliament'))) {
|
if (target === 'federal' && (repLevel.includes('mp') || repLevel.includes('member of parliament'))) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -930,14 +1065,38 @@ class CampaignsController {
|
|||||||
if (target === 'school board' && repLevel.includes('school')) {
|
if (target === 'school board' && repLevel.includes('school')) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
representatives: filteredRepresentatives,
|
representatives: allRecipients,
|
||||||
location: {
|
location: {
|
||||||
city: result?.city || cachedData[0]?.city || 'Alberta',
|
city: result?.city || cachedData[0]?.city || 'Alberta',
|
||||||
province: result?.province || cachedData[0]?.province || 'AB'
|
province: result?.province || cachedData[0]?.province || 'AB'
|
||||||
@ -989,9 +1148,9 @@ class CampaignsController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const analytics = await nocoDB.getCampaignAnalytics(id);
|
const analytics = await nocoDB.getCampaignAnalytics(id);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
analytics
|
analytics
|
||||||
@ -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
|
// Export controller instance and upload middleware
|
||||||
|
|||||||
283
influence/app/controllers/customRecipients.js
Normal 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();
|
||||||
262
influence/app/controllers/listmonkController.js
Normal 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'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -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();
|
module.exports = new RepresentativesController();
|
||||||
@ -1,4 +1,6 @@
|
|||||||
const nocodbService = require('../services/nocodb');
|
const nocodbService = require('../services/nocodb');
|
||||||
|
const emailService = require('../services/email');
|
||||||
|
const crypto = require('crypto');
|
||||||
const { validateResponse } = require('../utils/validators');
|
const { validateResponse } = require('../utils/validators');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -118,6 +120,48 @@ async function submitResponse(req, res) {
|
|||||||
screenshotUrl = `/uploads/responses/${req.file.filename}`;
|
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
|
// Prepare response data for NocoDB
|
||||||
const newResponse = {
|
const newResponse = {
|
||||||
campaign_id: campaign.ID || campaign.Id || campaign.id || campaign['Campaign ID'],
|
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_name: responseData.submitted_by_name || null,
|
||||||
submitted_by_email: responseData.submitted_by_email || null,
|
submitted_by_email: responseData.submitted_by_email || null,
|
||||||
submitted_by_user_id: req.user?.id || 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
|
status: 'pending', // All submissions start as pending
|
||||||
is_verified: false,
|
is_verified: false,
|
||||||
|
representative_email: representativeEmail,
|
||||||
|
verification_token: verificationToken,
|
||||||
|
verification_sent_at: verificationSentAt,
|
||||||
|
verified_at: null,
|
||||||
|
verified_by: null,
|
||||||
upvote_count: 0,
|
upvote_count: 0,
|
||||||
submitted_ip: req.ip || req.connection.remoteAddress
|
submitted_ip: req.ip || req.connection.remoteAddress
|
||||||
};
|
};
|
||||||
@ -144,10 +193,50 @@ async function submitResponse(req, res) {
|
|||||||
// Create response in database
|
// Create response in database
|
||||||
const createdResponse = await nocodbService.createRepresentativeResponse(newResponse);
|
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({
|
res.status(201).json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Response submitted successfully. It will be visible after moderation.',
|
message: responseMessage,
|
||||||
response: createdResponse
|
response: createdResponse,
|
||||||
|
verificationEmailSent
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -309,6 +398,12 @@ async function getResponseStats(req, res) {
|
|||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
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: {
|
stats: {
|
||||||
totalResponses,
|
totalResponses,
|
||||||
verifiedResponses,
|
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 = {
|
module.exports = {
|
||||||
getCampaignResponses,
|
getCampaignResponses,
|
||||||
submitResponse,
|
submitResponse,
|
||||||
@ -543,5 +1039,8 @@ module.exports = {
|
|||||||
getAdminResponses,
|
getAdminResponses,
|
||||||
updateResponseStatus,
|
updateResponseStatus,
|
||||||
updateResponse,
|
updateResponse,
|
||||||
deleteResponse
|
deleteResponse,
|
||||||
|
verifyResponse,
|
||||||
|
reportResponse,
|
||||||
|
resendVerification
|
||||||
};
|
};
|
||||||
|
|||||||
142
influence/app/middleware/csrf.js
Normal 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
|
||||||
|
};
|
||||||
@ -28,7 +28,17 @@
|
|||||||
"nodemailer": "^6.9.4",
|
"nodemailer": "^6.9.4",
|
||||||
"express-session": "^1.17.3",
|
"express-session": "^1.17.3",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"multer": "^1.4.5-lts.1"
|
"multer": "^1.4.5-lts.1",
|
||||||
|
"qrcode": "^1.5.3",
|
||||||
|
"winston": "^3.11.0",
|
||||||
|
"winston-daily-rotate-file": "^4.7.1",
|
||||||
|
"compression": "^1.7.4",
|
||||||
|
"csurf": "^1.11.0",
|
||||||
|
"cookie-parser": "^1.4.6",
|
||||||
|
"bull": "^4.12.0",
|
||||||
|
"prom-client": "^15.1.0",
|
||||||
|
"sharp": "^0.33.0",
|
||||||
|
"ioredis": "^5.3.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.0.1",
|
"nodemon": "^3.0.1",
|
||||||
|
|||||||
@ -106,6 +106,25 @@
|
|||||||
box-shadow: 0 4px 8px rgba(0,0,0,0.15);
|
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 {
|
.campaign-card-cover {
|
||||||
background-size: cover;
|
background-size: cover;
|
||||||
background-position: center;
|
background-position: center;
|
||||||
@ -762,6 +781,97 @@
|
|||||||
opacity: 0.8;
|
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) {
|
@media (max-width: 768px) {
|
||||||
.admin-nav {
|
.admin-nav {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -803,6 +913,7 @@
|
|||||||
<button class="nav-btn" data-tab="edit">Edit Campaign</button>
|
<button class="nav-btn" data-tab="edit">Edit Campaign</button>
|
||||||
<button class="nav-btn" data-tab="responses">Response Moderation</button>
|
<button class="nav-btn" data-tab="responses">Response Moderation</button>
|
||||||
<button class="nav-btn" data-tab="users">User Management</button>
|
<button class="nav-btn" data-tab="users">User Management</button>
|
||||||
|
<button class="nav-btn" data-tab="listmonk">📧 Email Sync</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- Success/Error Messages -->
|
<!-- Success/Error Messages -->
|
||||||
@ -918,13 +1029,30 @@ Sincerely,
|
|||||||
<input type="checkbox" id="create-allow-editing" name="allow_email_editing">
|
<input type="checkbox" id="create-allow-editing" name="allow_email_editing">
|
||||||
<label for="create-allow-editing">✏️ Allow Email Editing</label>
|
<label for="create-allow-editing">✏️ Allow Email Editing</label>
|
||||||
</div>
|
</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">
|
<div class="checkbox-item">
|
||||||
<input type="checkbox" id="create-show-response-wall" name="show_response_wall">
|
<input type="checkbox" id="create-show-response-wall" name="show_response_wall">
|
||||||
<label for="create-show-response-wall">💬 Show Response Wall Button</label>
|
<label for="create-show-response-wall">💬 Show Response Wall Button</label>
|
||||||
</div>
|
</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>
|
||||||
</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="section-header">🏛️ Target Government Levels</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="checkbox-group">
|
<div class="checkbox-group">
|
||||||
@ -1039,10 +1167,18 @@ Sincerely,
|
|||||||
<input type="checkbox" id="edit-allow-editing" name="allow_email_editing">
|
<input type="checkbox" id="edit-allow-editing" name="allow_email_editing">
|
||||||
<label for="edit-allow-editing">✏️ Allow Email Editing</label>
|
<label for="edit-allow-editing">✏️ Allow Email Editing</label>
|
||||||
</div>
|
</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">
|
<div class="checkbox-item">
|
||||||
<input type="checkbox" id="edit-show-response-wall" name="show_response_wall">
|
<input type="checkbox" id="edit-show-response-wall" name="show_response_wall">
|
||||||
<label for="edit-show-response-wall">💬 Show Response Wall Button</label>
|
<label for="edit-show-response-wall">💬 Show Response Wall Button</label>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -1073,6 +1209,67 @@ Sincerely,
|
|||||||
<button type="button" class="btn btn-secondary" data-action="cancel-edit">Cancel</button>
|
<button type="button" class="btn btn-secondary" data-action="cancel-edit">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Response Moderation Tab -->
|
<!-- Response Moderation Tab -->
|
||||||
@ -1124,6 +1321,80 @@ Sincerely,
|
|||||||
<!-- Users will be loaded here -->
|
<!-- Users will be loaded here -->
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<!-- User Modal -->
|
<!-- User Modal -->
|
||||||
@ -1199,8 +1470,54 @@ Sincerely,
|
|||||||
</div>
|
</div>
|
||||||
</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">×</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/api-client.js"></script>
|
||||||
<script src="js/auth.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>
|
<script src="js/admin.js"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@ -130,6 +130,75 @@
|
|||||||
border-color: rgba(40, 167, 69, 1);
|
border-color: rgba(40, 167, 69, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.share-more-container {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-dropdown {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
right: 0;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
padding: 0.5rem;
|
||||||
|
z-index: 1000;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-dropdown.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-dropdown-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-dropdown-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 0.5rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(52, 152, 219, 0.1);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-dropdown-item:hover {
|
||||||
|
background: rgba(52, 152, 219, 0.2);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-dropdown-item svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
fill: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-dropdown-item span {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-btn-small.more-btn {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-btn-small.more-btn.active {
|
||||||
|
background: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
.campaign-content {
|
.campaign-content {
|
||||||
max-width: 800px;
|
max-width: 800px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@ -234,6 +303,11 @@
|
|||||||
transition: transform 0.2s, box-shadow 0.2s;
|
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 {
|
.rep-card:hover {
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||||
@ -252,11 +326,30 @@
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
background: #e9ecef;
|
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 {
|
.rep-details h4 {
|
||||||
margin: 0 0 0.25rem 0;
|
margin: 0 0 0.25rem 0;
|
||||||
color: #2c3e50;
|
color: #2c3e50;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rep-details p {
|
.rep-details p {
|
||||||
@ -463,35 +556,110 @@
|
|||||||
|
|
||||||
<!-- Social Share Buttons in Header -->
|
<!-- Social Share Buttons in Header -->
|
||||||
<div class="share-buttons-header">
|
<div class="share-buttons-header">
|
||||||
<button class="share-btn-small" id="share-facebook" title="Share on Facebook">
|
<!-- Expandable Social Menu -->
|
||||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
<div class="share-socials-container">
|
||||||
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
|
<button class="share-btn-primary" id="share-socials-toggle" title="Share on Socials">
|
||||||
</svg>
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class="share-icon">
|
||||||
</button>
|
<path d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.16c-.05.21-.08.43-.08.65 0 1.61 1.31 2.92 2.92 2.92 1.61 0 2.92-1.31 2.92-2.92s-1.31-2.92-2.92-2.92z"/>
|
||||||
<button class="share-btn-small" id="share-twitter" title="Share on Twitter/X">
|
</svg>
|
||||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
<span>Socials</span>
|
||||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class="chevron-icon">
|
||||||
</svg>
|
<path d="M7 10l5 5 5-5z"/>
|
||||||
</button>
|
</svg>
|
||||||
<button class="share-btn-small" id="share-linkedin" title="Share on LinkedIn">
|
</button>
|
||||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
|
<!-- Expandable Social Options -->
|
||||||
</svg>
|
<div class="share-socials-menu" id="share-socials-menu">
|
||||||
</button>
|
<button class="share-btn-small" id="share-facebook" title="Facebook">
|
||||||
<button class="share-btn-small" id="share-whatsapp" title="Share on WhatsApp">
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
|
||||||
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"/>
|
</svg>
|
||||||
</svg>
|
</button>
|
||||||
</button>
|
<button class="share-btn-small" id="share-twitter" title="Twitter/X">
|
||||||
<button class="share-btn-small" id="share-email" title="Share via Email">
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
|
||||||
<path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/>
|
</svg>
|
||||||
</svg>
|
</button>
|
||||||
</button>
|
<button class="share-btn-small" id="share-linkedin" title="LinkedIn">
|
||||||
<button class="share-btn-small" id="share-copy" title="Copy Link">
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="share-btn-small" id="share-whatsapp" title="WhatsApp">
|
||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="share-btn-small" id="share-bluesky" title="Bluesky">
|
||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.206-.659-.298-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8Z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="share-btn-small" id="share-instagram" title="Instagram">
|
||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M7.8 2h8.4C19.4 2 22 4.6 22 7.8v8.4a5.8 5.8 0 0 1-5.8 5.8H7.8C4.6 22 2 19.4 2 16.2V7.8A5.8 5.8 0 0 1 7.8 2m-.2 2A3.6 3.6 0 0 0 4 7.6v8.8C4 18.39 5.61 20 7.6 20h8.8a3.6 3.6 0 0 0 3.6-3.6V7.6C20 5.61 18.39 4 16.4 4H7.6m9.65 1.5a1.25 1.25 0 0 1 1.25 1.25A1.25 1.25 0 0 1 17.25 8 1.25 1.25 0 0 1 16 6.75a1.25 1.25 0 0 1 1.25-1.25M12 7a5 5 0 0 1 5 5 5 5 0 0 1-5 5 5 5 0 0 1-5-5 5 5 0 0 1 5-5m0 2a3 3 0 0 0-3 3 3 3 0 0 0 3 3 3 3 0 0 0 3-3 3 3 0 0 0-3-3z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="share-btn-small" id="share-reddit" title="Reddit">
|
||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 0 0 .029-.463.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 0 0-.232-.095z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="share-btn-small" id="share-threads" title="Threads">
|
||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M12.186 24h-.007c-3.581-.024-6.334-1.205-8.184-3.509C2.35 18.44 1.5 15.586 1.472 12.01v-.017c.03-3.579.879-6.43 2.525-8.482C5.845 1.205 8.6.024 12.18 0h.014c2.746.02 5.043.725 6.826 2.098 1.677 1.29 2.858 3.13 3.509 5.467l-2.04.569c-.542-1.947-1.499-3.488-2.846-4.576-1.488-1.2-3.457-1.806-5.854-1.826h-.01c-3.015.022-5.26.918-6.675 2.662C3.873 6.034 3.13 8.39 3.108 11.98v.014c.022 3.585.766 5.937 2.209 6.99 1.407 1.026 3.652 1.545 6.674 1.545h.01c.297 0 .597-.002.892-.009l.034 2.037c-.33.008-.665.012-1.001.012zM17.822 15.13v.002c-.184 1.376-.865 2.465-2.025 3.234-1.222.81-2.878 1.221-4.922 1.221-1.772 0-3.185-.34-4.197-1.009-.944-.625-1.488-1.527-1.617-2.68-.119-1.066.152-2.037.803-2.886.652-.85 1.595-1.464 2.802-1.823 1.102-.33 2.396-.495 3.847-.495h.343v1.615h-.343c-1.274 0-2.395.144-3.332.428-.937.284-1.653.713-2.129 1.275-.476.562-.664 1.229-.556 1.979.097.671.45 1.21 1.051 1.603.723.473 1.816.711 3.252.711 1.738 0 3.097-.35 4.042-.995.809-.552 1.348-1.349 1.603-2.373l1.98.193zM12.626 10.561v.002c-1.197 0-2.234.184-3.083.546-.938.4-1.668 1.017-2.169 1.835-.499.816-.748 1.792-.739 2.902l-2.037-.022c-.012-1.378.304-2.608.939-3.658.699-1.158 1.688-2.065 2.941-2.696 1.05-.527 2.274-.792 3.638-.792h.51v1.883h-.51z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="share-btn-small" id="share-telegram" title="Telegram">
|
||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="share-btn-small" id="share-mastodon" title="Mastodon">
|
||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="share-btn-small" id="share-sms" title="SMS">
|
||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12zM7 9h2v2H7zm4 0h2v2h-2zm4 0h2v2h-2z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="share-btn-small" id="share-slack" title="Slack">
|
||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.122 2.521a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.268 0a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zm-2.523 10.122a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zm0-1.268a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="share-btn-small" id="share-discord" title="Discord">
|
||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="share-btn-small" id="share-print" title="Print">
|
||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M19 8H5c-1.66 0-3 1.34-3 3v6h4v4h12v-4h4v-6c0-1.66-1.34-3-3-3zm-3 11H8v-5h8v5zm3-7c-.55 0-1-.45-1-1s.45-1 1-1 1 .45 1 1-.45 1-1 1zm-1-9H6v4h12V3z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="share-btn-small" id="share-email" title="Email">
|
||||||
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Always-visible buttons -->
|
||||||
|
<button class="share-btn-primary" id="share-copy" title="Copy Link">
|
||||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
|
<path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span>Copy Link</span>
|
||||||
|
</button>
|
||||||
|
<button 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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -539,15 +707,6 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Response Wall Button -->
|
|
||||||
<div id="response-wall-section" class="response-wall-container" style="display: none;">
|
|
||||||
<h3>💬 See What People Are Saying</h3>
|
|
||||||
<p>Check out responses to people who have taken action on this campaign</p>
|
|
||||||
<a href="#" id="response-wall-link" class="response-wall-button">
|
|
||||||
View Response Wall
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Email Preview -->
|
<!-- Email Preview -->
|
||||||
<div id="email-preview" class="email-preview preview-mode" style="display: none;">
|
<div id="email-preview" class="email-preview preview-mode" style="display: none;">
|
||||||
<h3>📧 Email Preview</h3>
|
<h3>📧 Email Preview</h3>
|
||||||
@ -594,6 +753,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Response Wall Button -->
|
||||||
|
<div id="response-wall-section" class="response-wall-container" style="display: none;">
|
||||||
|
<h3>💬 See What People Are Saying</h3>
|
||||||
|
<p>Check out responses to people who have taken action on this campaign</p>
|
||||||
|
<a href="#" id="response-wall-link" class="response-wall-button">
|
||||||
|
View Response Wall
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Success Message -->
|
<!-- Success Message -->
|
||||||
<div id="success-section" style="display: none; text-align: center; padding: 2rem;">
|
<div id="success-section" style="display: none; text-align: center; padding: 2rem;">
|
||||||
<h2 style="color: #27ae60;">🎉 Thank you for taking action!</h2>
|
<h2 style="color: #27ae60;">🎉 Thank you for taking action!</h2>
|
||||||
@ -605,11 +773,28 @@
|
|||||||
<div id="error-message" class="error-message" style="display: none;"></div>
|
<div id="error-message" class="error-message" style="display: none;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- QR Code Modal -->
|
||||||
|
<div id="qrcode-modal" class="qrcode-modal">
|
||||||
|
<div class="qrcode-modal-content">
|
||||||
|
<span class="qrcode-close">×</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;">
|
<footer style="text-align: center; margin-top: 2rem; padding: 1rem; border-top: 1px solid #e9ecef;">
|
||||||
<p>© 2025 <a href="https://bnkops.com/" target="_blank">BNKops</a> Influence Tool. Connect with democracy.</p>
|
<p>© 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>
|
</footer>
|
||||||
|
|
||||||
|
<script src="/js/api-client.js"></script>
|
||||||
<script src="/js/campaign.js"></script>
|
<script src="/js/campaign.js"></script>
|
||||||
<script>
|
<script>
|
||||||
// Update footer links with APP_URL if needed for cross-origin scenarios
|
// Update footer links with APP_URL if needed for cross-origin scenarios
|
||||||
|
|||||||
@ -1,5 +1,315 @@
|
|||||||
/* Response Wall Styles */
|
/* 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 {
|
.stats-banner {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
@ -229,6 +539,38 @@
|
|||||||
font-weight: bold;
|
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 */
|
||||||
.empty-state {
|
.empty-state {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@ -303,6 +645,47 @@
|
|||||||
color: #7f8c8d;
|
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 {
|
.form-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
@ -313,6 +696,25 @@
|
|||||||
flex: 1;
|
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 {
|
.loading {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
@ -359,9 +761,124 @@
|
|||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.response-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verify-btn,
|
||||||
|
.upvote-btn {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
margin: 10% 5%;
|
margin: 10% 5%;
|
||||||
padding: 1rem;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -15,14 +15,31 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<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>
|
<main>
|
||||||
<!-- Postal Code Input Section -->
|
<!-- 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">
|
<div class="map-header">
|
||||||
<h2>Find Your Representatives</h2>
|
<h2>Find Your Representatives</h2>
|
||||||
</div>
|
</div>
|
||||||
@ -55,6 +72,13 @@
|
|||||||
<p>Looking up your representatives...</p>
|
<p>Looking up your representatives...</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</section>
|
||||||
|
|
||||||
<!-- Representatives Display Section -->
|
<!-- Representatives Display Section -->
|
||||||
@ -78,7 +102,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Email Compose Modal -->
|
<!-- epose Modal -->
|
||||||
<div id="email-modal" class="modal" style="display: none;">
|
<div id="email-modal" class="modal" style="display: none;">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
|
|||||||
@ -8,6 +8,7 @@ class AdminPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
|
console.log('AdminPanel init started');
|
||||||
// Check authentication first
|
// Check authentication first
|
||||||
if (typeof authManager !== 'undefined') {
|
if (typeof authManager !== 'undefined') {
|
||||||
this.authManager = authManager;
|
this.authManager = authManager;
|
||||||
@ -23,9 +24,12 @@ class AdminPanel {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('Setting up event listeners and form interactions');
|
||||||
this.setupEventListeners();
|
this.setupEventListeners();
|
||||||
this.setupFormInteractions();
|
this.setupFormInteractions();
|
||||||
|
this.setupCustomRecipientsHandlers();
|
||||||
this.loadCampaigns();
|
this.loadCampaigns();
|
||||||
|
console.log('AdminPanel init completed');
|
||||||
}
|
}
|
||||||
|
|
||||||
setupUserInterface() {
|
setupUserInterface() {
|
||||||
@ -484,7 +488,8 @@ class AdminPanel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
listDiv.innerHTML = this.campaigns.map(campaign => `
|
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 ? `
|
${campaign.cover_photo ? `
|
||||||
<div class="campaign-card-cover" style="background-image: url('/uploads/${campaign.cover_photo}');">
|
<div class="campaign-card-cover" style="background-image: url('/uploads/${campaign.cover_photo}');">
|
||||||
<div class="campaign-card-cover-overlay">
|
<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('show_email_count', formData.get('show_email_count') === 'on');
|
||||||
campaignFormData.append('allow_email_editing', formData.get('allow_email_editing') === '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('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
|
// Handle target_government_levels array
|
||||||
const targetLevels = Array.from(formData.getAll('target_government_levels'));
|
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="show_email_count"]').checked = campaign.show_email_count;
|
||||||
form.querySelector('[name="allow_email_editing"]').checked = campaign.allow_email_editing;
|
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="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
|
// Government levels
|
||||||
let targetLevels = [];
|
let targetLevels = [];
|
||||||
@ -682,6 +712,8 @@ class AdminPanel {
|
|||||||
updateFormData.append('show_email_count', formData.get('show_email_count') === 'on');
|
updateFormData.append('show_email_count', formData.get('show_email_count') === 'on');
|
||||||
updateFormData.append('allow_email_editing', formData.get('allow_email_editing') === '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('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
|
// Handle target_government_levels array
|
||||||
const targetLevels = Array.from(formData.getAll('target_government_levels'));
|
const targetLevels = Array.from(formData.getAll('target_government_levels'));
|
||||||
@ -1329,6 +1361,53 @@ class AdminPanel {
|
|||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
return div.innerHTML;
|
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
|
// Initialize admin panel when DOM is loaded
|
||||||
|
|||||||
@ -2,14 +2,66 @@
|
|||||||
class APIClient {
|
class APIClient {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.baseURL = '/api';
|
this.baseURL = '/api';
|
||||||
|
this.csrfToken = null;
|
||||||
|
this.csrfTokenPromise = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async makeRequest(endpoint, options = {}) {
|
/**
|
||||||
|
* 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();
|
||||||
|
}
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
...(needsCsrf && this.csrfToken ? { 'X-CSRF-Token': this.csrfToken } : {}),
|
||||||
...options.headers
|
...options.headers
|
||||||
},
|
},
|
||||||
|
credentials: 'include', // Important: include cookies for CSRF
|
||||||
...options
|
...options
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -17,7 +69,21 @@ class APIClient {
|
|||||||
const response = await fetch(`${this.baseURL}${endpoint}`, config);
|
const response = await fetch(`${this.baseURL}${endpoint}`, config);
|
||||||
const data = await response.json();
|
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 (!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
|
// Create enhanced error with response data for better error handling
|
||||||
const error = new Error(data.error || data.message || `HTTP ${response.status}`);
|
const error = new Error(data.error || data.message || `HTTP ${response.status}`);
|
||||||
error.status = 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
|
// Don't set Content-Type header - browser will set it with boundary for multipart/form-data
|
||||||
|
// But DO set CSRF token header
|
||||||
const config = {
|
const config = {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData
|
headers: {
|
||||||
|
...(this.csrfToken ? { 'X-CSRF-Token': this.csrfToken } : {})
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
credentials: 'include' // Important: include cookies for CSRF
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${this.baseURL}${endpoint}`, config);
|
const response = await fetch(`${this.baseURL}${endpoint}`, config);
|
||||||
const data = await response.json();
|
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 (!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}`);
|
const error = new Error(data.error || data.message || `HTTP ${response.status}`);
|
||||||
error.status = response.status;
|
error.status = response.status;
|
||||||
error.data = data;
|
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
|
// Don't set Content-Type header - browser will set it with boundary for multipart/form-data
|
||||||
|
// But DO set CSRF token header
|
||||||
const config = {
|
const config = {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: formData
|
headers: {
|
||||||
|
...(this.csrfToken ? { 'X-CSRF-Token': this.csrfToken } : {})
|
||||||
|
},
|
||||||
|
body: formData,
|
||||||
|
credentials: 'include' // Important: include cookies for CSRF
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${this.baseURL}${endpoint}`, config);
|
const response = await fetch(`${this.baseURL}${endpoint}`, config);
|
||||||
const data = await response.json();
|
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 (!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}`);
|
const error = new Error(data.error || data.message || `HTTP ${response.status}`);
|
||||||
error.status = response.status;
|
error.status = response.status;
|
||||||
error.data = data;
|
error.data = data;
|
||||||
|
|||||||
@ -33,7 +33,27 @@ class CampaignPage {
|
|||||||
setupShareButtons() {
|
setupShareButtons() {
|
||||||
// Get current URL
|
// Get current URL
|
||||||
const shareUrl = window.location.href;
|
const shareUrl = window.location.href;
|
||||||
|
|
||||||
|
// Social menu toggle
|
||||||
|
const socialsToggle = document.getElementById('share-socials-toggle');
|
||||||
|
const socialsMenu = document.getElementById('share-socials-menu');
|
||||||
|
|
||||||
|
if (socialsToggle && socialsMenu) {
|
||||||
|
socialsToggle.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
socialsMenu.classList.toggle('show');
|
||||||
|
socialsToggle.classList.toggle('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close menu when clicking outside
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
if (!e.target.closest('.share-socials-container')) {
|
||||||
|
socialsMenu.classList.remove('show');
|
||||||
|
socialsToggle.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Facebook share
|
// Facebook share
|
||||||
document.getElementById('share-facebook')?.addEventListener('click', () => {
|
document.getElementById('share-facebook')?.addEventListener('click', () => {
|
||||||
const url = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`;
|
const url = `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(shareUrl)}`;
|
||||||
@ -60,6 +80,93 @@ class CampaignPage {
|
|||||||
window.open(url, '_blank');
|
window.open(url, '_blank');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Bluesky share
|
||||||
|
document.getElementById('share-bluesky')?.addEventListener('click', () => {
|
||||||
|
const text = this.campaign ? `Check out this campaign: ${this.campaign.title}` : 'Check out this campaign';
|
||||||
|
const url = `https://bsky.app/intent/compose?text=${encodeURIComponent(text + ' ' + shareUrl)}`;
|
||||||
|
window.open(url, '_blank', 'width=600,height=400');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Instagram share (note: Instagram doesn't have direct web share, opens Instagram web)
|
||||||
|
document.getElementById('share-instagram')?.addEventListener('click', () => {
|
||||||
|
alert('To share on Instagram:\n1. Copy the link (use the copy button)\n2. Open Instagram app\n3. Create a post or story\n4. Paste the link in your caption');
|
||||||
|
// Automatically copy the link
|
||||||
|
navigator.clipboard.writeText(shareUrl).catch(() => {
|
||||||
|
console.log('Failed to copy link automatically');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reddit share
|
||||||
|
document.getElementById('share-reddit')?.addEventListener('click', () => {
|
||||||
|
const title = this.campaign ? `${this.campaign.title}` : 'Check out this campaign';
|
||||||
|
const url = `https://www.reddit.com/submit?url=${encodeURIComponent(shareUrl)}&title=${encodeURIComponent(title)}`;
|
||||||
|
window.open(url, '_blank', 'width=800,height=600');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Threads share
|
||||||
|
document.getElementById('share-threads')?.addEventListener('click', () => {
|
||||||
|
const text = this.campaign ? `Check out this campaign: ${this.campaign.title}` : 'Check out this campaign';
|
||||||
|
const url = `https://threads.net/intent/post?text=${encodeURIComponent(text + ' ' + shareUrl)}`;
|
||||||
|
window.open(url, '_blank', 'width=600,height=400');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Telegram share
|
||||||
|
document.getElementById('share-telegram')?.addEventListener('click', () => {
|
||||||
|
const text = this.campaign ? `Check out this campaign: ${this.campaign.title}` : 'Check out this campaign';
|
||||||
|
const url = `https://t.me/share/url?url=${encodeURIComponent(shareUrl)}&text=${encodeURIComponent(text)}`;
|
||||||
|
window.open(url, '_blank', 'width=600,height=400');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mastodon share
|
||||||
|
document.getElementById('share-mastodon')?.addEventListener('click', () => {
|
||||||
|
const text = this.campaign ? `Check out this campaign: ${this.campaign.title}` : 'Check out this campaign';
|
||||||
|
// Mastodon requires instance selection - opens a composer with text
|
||||||
|
const instance = prompt('Enter your Mastodon instance (e.g., mastodon.social):');
|
||||||
|
if (instance) {
|
||||||
|
const url = `https://${instance}/share?text=${encodeURIComponent(text + ' ' + shareUrl)}`;
|
||||||
|
window.open(url, '_blank', 'width=600,height=400');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// SMS share
|
||||||
|
document.getElementById('share-sms')?.addEventListener('click', () => {
|
||||||
|
const text = this.campaign ? `Check out this campaign: ${this.campaign.title}` : 'Check out this campaign';
|
||||||
|
const body = text + ' ' + shareUrl;
|
||||||
|
// Use Web Share API if available, otherwise fallback to SMS protocol
|
||||||
|
if (navigator.share) {
|
||||||
|
navigator.share({
|
||||||
|
title: this.campaign ? this.campaign.title : 'Campaign',
|
||||||
|
text: body
|
||||||
|
}).catch(() => {
|
||||||
|
// Fallback to SMS protocol
|
||||||
|
window.location.href = `sms:?&body=${encodeURIComponent(body)}`;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// SMS protocol (works on mobile)
|
||||||
|
window.location.href = `sms:?&body=${encodeURIComponent(body)}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Slack share
|
||||||
|
document.getElementById('share-slack')?.addEventListener('click', () => {
|
||||||
|
const url = `https://slack.com/intl/en-ca/share?url=${encodeURIComponent(shareUrl)}`;
|
||||||
|
window.open(url, '_blank', 'width=600,height=400');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Discord share
|
||||||
|
document.getElementById('share-discord')?.addEventListener('click', () => {
|
||||||
|
alert('To share on Discord:\n1. Copy the link (use the copy button)\n2. Open Discord\n3. Paste the link in any channel or DM\n\nDiscord will automatically create a preview!');
|
||||||
|
// Automatically copy the link
|
||||||
|
navigator.clipboard.writeText(shareUrl).catch(() => {
|
||||||
|
console.log('Failed to copy link automatically');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Print/PDF share
|
||||||
|
document.getElementById('share-print')?.addEventListener('click', () => {
|
||||||
|
window.print();
|
||||||
|
});
|
||||||
|
|
||||||
// Email share
|
// Email share
|
||||||
document.getElementById('share-email')?.addEventListener('click', () => {
|
document.getElementById('share-email')?.addEventListener('click', () => {
|
||||||
const subject = this.campaign ? `Campaign: ${this.campaign.title}` : 'Check out this campaign';
|
const subject = this.campaign ? `Campaign: ${this.campaign.title}` : 'Check out this campaign';
|
||||||
@ -105,14 +212,74 @@ class CampaignPage {
|
|||||||
document.body.removeChild(textArea);
|
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() {
|
async loadCampaign() {
|
||||||
this.showLoading('Loading campaign...');
|
this.showLoading('Loading campaign...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/campaigns/${this.campaignSlug}`);
|
const data = await window.apiClient.get(`/campaigns/${this.campaignSlug}`);
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
throw new Error(data.error || 'Failed to load campaign');
|
throw new Error(data.error || 'Failed to load campaign');
|
||||||
@ -419,19 +586,11 @@ class CampaignPage {
|
|||||||
|
|
||||||
async trackUserInfo() {
|
async trackUserInfo() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/campaigns/${this.campaignSlug}/track-user`, {
|
const data = await window.apiClient.post(`/campaigns/${this.campaignSlug}/track-user`, {
|
||||||
method: 'POST',
|
userEmail: this.userInfo.userEmail,
|
||||||
headers: {
|
userName: this.userInfo.userName,
|
||||||
'Content-Type': 'application/json'
|
postalCode: this.userInfo.postalCode
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
userEmail: this.userInfo.userEmail,
|
|
||||||
userName: this.userInfo.userName,
|
|
||||||
postalCode: this.userInfo.postalCode
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
console.warn('Failed to track user info:', data.error);
|
console.warn('Failed to track user info:', data.error);
|
||||||
@ -447,8 +606,7 @@ class CampaignPage {
|
|||||||
this.showLoading('Finding your representatives...');
|
this.showLoading('Finding your representatives...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/campaigns/${this.campaignSlug}/representatives/${this.userInfo.postalCode}`);
|
const data = await window.apiClient.get(`/campaigns/${this.campaignSlug}/representatives/${this.userInfo.postalCode}`);
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
throw new Error(data.error || 'Failed to load representatives');
|
throw new Error(data.error || 'Failed to load representatives');
|
||||||
@ -478,14 +636,17 @@ class CampaignPage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
list.innerHTML = this.representatives.map(rep => `
|
list.innerHTML = this.representatives.map(rep => `
|
||||||
<div class="rep-card">
|
<div class="rep-card ${rep.is_custom_recipient ? 'custom-recipient' : ''}">
|
||||||
<div class="rep-info">
|
<div class="rep-info">
|
||||||
${rep.photo_url ?
|
${rep.photo_url ?
|
||||||
`<img src="${rep.photo_url}" alt="${rep.name}" class="rep-photo">` :
|
`<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">
|
<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.elected_office || 'Representative'}</p>
|
||||||
<p>${rep.party_name || ''}</p>
|
<p>${rep.party_name || ''}</p>
|
||||||
${rep.email ? `<p>📧 ${rep.email}</p>` : ''}
|
${rep.email ? `<p>📧 ${rep.email}</p>` : ''}
|
||||||
@ -498,11 +659,12 @@ class CampaignPage {
|
|||||||
data-email="${rep.email}"
|
data-email="${rep.email}"
|
||||||
data-name="${rep.name}"
|
data-name="${rep.name}"
|
||||||
data-title="${rep.elected_office || ''}"
|
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
|
Send Email
|
||||||
</button>
|
</button>
|
||||||
` : ''}
|
` : ''}
|
||||||
${this.getPhoneNumber(rep) ? `
|
${this.getPhoneNumber(rep) && !rep.is_custom_recipient ? `
|
||||||
<button class="btn btn-success" data-action="call-representative"
|
<button class="btn btn-success" data-action="call-representative"
|
||||||
data-phone="${this.getPhoneNumber(rep)}"
|
data-phone="${this.getPhoneNumber(rep)}"
|
||||||
data-name="${rep.name}"
|
data-name="${rep.name}"
|
||||||
@ -530,7 +692,8 @@ class CampaignPage {
|
|||||||
const name = e.target.dataset.name;
|
const name = e.target.dataset.name;
|
||||||
const title = e.target.dataset.title;
|
const title = e.target.dataset.title;
|
||||||
const level = e.target.dataset.level;
|
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,23 +767,15 @@ class CampaignPage {
|
|||||||
|
|
||||||
async trackCall(phone, name, title, officeType) {
|
async trackCall(phone, name, title, officeType) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/campaigns/${this.campaignSlug}/track-call`, {
|
const data = await window.apiClient.post(`/campaigns/${this.campaignSlug}/track-call`, {
|
||||||
method: 'POST',
|
representativeName: name,
|
||||||
headers: {
|
representativeTitle: title || '',
|
||||||
'Content-Type': 'application/json'
|
phoneNumber: phone,
|
||||||
},
|
officeType: officeType || '',
|
||||||
body: JSON.stringify({
|
userEmail: this.userInfo.userEmail,
|
||||||
representativeName: name,
|
userName: this.userInfo.userName,
|
||||||
representativeTitle: title || '',
|
postalCode: this.userInfo.postalCode
|
||||||
phoneNumber: phone,
|
|
||||||
officeType: officeType || '',
|
|
||||||
userEmail: this.userInfo.userEmail,
|
|
||||||
userName: this.userInfo.userName,
|
|
||||||
postalCode: this.userInfo.postalCode
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
this.showCallSuccess('Call tracked successfully!');
|
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;
|
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') {
|
if (emailMethod === 'mailto') {
|
||||||
this.openMailtoLink(recipientEmail);
|
this.openMailtoLink(recipientEmail, recipientName, recipientTitle, finalLevel);
|
||||||
} else {
|
} 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 subject = encodeURIComponent(this.currentEmailSubject || this.campaign.email_subject);
|
||||||
const body = encodeURIComponent(this.currentEmailBody || this.campaign.email_body);
|
const body = encodeURIComponent(this.currentEmailBody || this.campaign.email_body);
|
||||||
const mailtoUrl = `mailto:${recipientEmail}?subject=${subject}&body=${body}`;
|
const mailtoUrl = `mailto:${recipientEmail}?subject=${subject}&body=${body}`;
|
||||||
|
|
||||||
// Track the mailto click
|
// Track the mailto click
|
||||||
this.trackEmail(recipientEmail, '', '', '', 'mailto');
|
this.trackEmail(recipientEmail, recipientName, recipientTitle, recipientLevel, 'mailto');
|
||||||
|
|
||||||
window.open(mailtoUrl);
|
window.open(mailtoUrl);
|
||||||
}
|
}
|
||||||
@ -671,15 +829,7 @@ class CampaignPage {
|
|||||||
emailData.customEmailBody = this.currentEmailBody || this.campaign.email_body;
|
emailData.customEmailBody = this.currentEmailBody || this.campaign.email_body;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`/api/campaigns/${this.campaignSlug}/send-email`, {
|
const data = await window.apiClient.post(`/campaigns/${this.campaignSlug}/send-email`, emailData);
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify(emailData)
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
this.showSuccess('Email sent successfully!');
|
this.showSuccess('Email sent successfully!');
|
||||||
@ -702,21 +852,15 @@ class CampaignPage {
|
|||||||
|
|
||||||
async trackEmail(recipientEmail, recipientName, recipientTitle, recipientLevel, emailMethod) {
|
async trackEmail(recipientEmail, recipientName, recipientTitle, recipientLevel, emailMethod) {
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/campaigns/${this.campaignSlug}/send-email`, {
|
await window.apiClient.post(`/campaigns/${this.campaignSlug}/send-email`, {
|
||||||
method: 'POST',
|
userEmail: this.userInfo.userEmail,
|
||||||
headers: {
|
userName: this.userInfo.userName,
|
||||||
'Content-Type': 'application/json'
|
postalCode: this.userInfo.postalCode,
|
||||||
},
|
recipientEmail,
|
||||||
body: JSON.stringify({
|
recipientName,
|
||||||
userEmail: this.userInfo.userEmail,
|
recipientTitle,
|
||||||
userName: this.userInfo.userName,
|
recipientLevel,
|
||||||
postalCode: this.userInfo.postalCode,
|
emailMethod
|
||||||
recipientEmail,
|
|
||||||
recipientName,
|
|
||||||
recipientTitle,
|
|
||||||
recipientLevel,
|
|
||||||
emailMethod
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to track email:', error);
|
console.error('Failed to track email:', error);
|
||||||
|
|||||||
@ -72,11 +72,27 @@ class CampaignsGrid {
|
|||||||
|
|
||||||
const campaignsHTML = sortedCampaigns.map(campaign => this.renderCampaignCard(campaign)).join('');
|
const campaignsHTML = sortedCampaigns.map(campaign => this.renderCampaignCard(campaign)).join('');
|
||||||
this.container.innerHTML = campaignsHTML;
|
this.container.innerHTML = campaignsHTML;
|
||||||
|
|
||||||
|
// Trigger animations by forcing a reflow
|
||||||
|
this.triggerAnimations();
|
||||||
|
|
||||||
// Add click event listeners to campaign cards (no inline handlers)
|
// Add click event listeners to campaign cards (no inline handlers)
|
||||||
this.attachCardClickHandlers();
|
this.attachCardClickHandlers();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
triggerAnimations() {
|
||||||
|
// Force reflow to restart CSS animations when campaigns are re-rendered
|
||||||
|
const cards = this.container.querySelectorAll('.campaign-card');
|
||||||
|
cards.forEach((card, index) => {
|
||||||
|
// Remove and re-add animation to restart it
|
||||||
|
card.style.animation = 'none';
|
||||||
|
// Force reflow
|
||||||
|
void card.offsetHeight;
|
||||||
|
// Re-apply animation with staggered delay
|
||||||
|
card.style.animation = `campaignFadeInUp 0.6s ease ${0.1 * (index + 1)}s forwards`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
attachCardClickHandlers() {
|
attachCardClickHandlers() {
|
||||||
const campaignCards = this.container.querySelectorAll('.campaign-card');
|
const campaignCards = this.container.querySelectorAll('.campaign-card');
|
||||||
campaignCards.forEach(card => {
|
campaignCards.forEach(card => {
|
||||||
@ -143,6 +159,14 @@ class CampaignsGrid {
|
|||||||
</div>`
|
</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
|
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('')
|
? 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" data-slug="${campaign.slug}">
|
||||||
<div class="campaign-card-image" style="${coverPhotoStyle}">
|
<div class="campaign-card-image" style="${coverPhotoStyle}">
|
||||||
<div class="campaign-card-overlay"></div>
|
<div class="campaign-card-overlay"></div>
|
||||||
|
<h3 class="campaign-card-title">${this.escapeHtml(campaign.title)}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="campaign-card-content">
|
<div class="campaign-card-content">
|
||||||
<h3 class="campaign-card-title">${this.escapeHtml(campaign.title)}</h3>
|
|
||||||
<p class="campaign-card-description">${this.escapeHtml(truncatedDescription)}</p>
|
<p class="campaign-card-description">${this.escapeHtml(truncatedDescription)}</p>
|
||||||
${targetLevels ? `<div class="campaign-card-levels">${targetLevels}</div>` : ''}
|
${targetLevels ? `<div class="campaign-card-levels">${targetLevels}</div>` : ''}
|
||||||
<div class="campaign-card-stats">
|
<div class="campaign-card-stats">
|
||||||
@ -199,6 +223,7 @@ class CampaignsGrid {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
${verifiedResponseBadge}
|
||||||
<div class="campaign-card-action">
|
<div class="campaign-card-action">
|
||||||
<span class="btn-link">Learn More & Participate →</span>
|
<span class="btn-link">Learn More & Participate →</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
525
influence/app/public/js/custom-recipients.js
Normal 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 = {
|
||||||
|
'&': '&',
|
||||||
|
'<': '<',
|
||||||
|
'>': '>',
|
||||||
|
'"': '"',
|
||||||
|
"'": '''
|
||||||
|
};
|
||||||
|
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;
|
||||||
465
influence/app/public/js/listmonk-admin.js
Normal 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;
|
||||||
@ -4,13 +4,23 @@ class MainApp {
|
|||||||
this.init();
|
this.init();
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
async init() {
|
||||||
// Initialize message display system
|
// Initialize message display system
|
||||||
window.messageDisplay = new MessageDisplay();
|
window.messageDisplay = new MessageDisplay();
|
||||||
|
|
||||||
// Check API health on startup
|
// Check API health on startup
|
||||||
this.checkAPIHealth();
|
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
|
// Add global error handling
|
||||||
window.addEventListener('error', (e) => {
|
window.addEventListener('error', (e) => {
|
||||||
// Only log and show message for actual errors, not null/undefined
|
// 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');
|
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
|
// Message Display System
|
||||||
|
|||||||
@ -15,7 +15,12 @@ class PostalLookup {
|
|||||||
|
|
||||||
init() {
|
init() {
|
||||||
this.form.addEventListener('submit', (e) => this.handleSubmit(e));
|
this.form.addEventListener('submit', (e) => this.handleSubmit(e));
|
||||||
this.refreshBtn.addEventListener('click', () => this.handleRefresh());
|
|
||||||
|
// 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));
|
this.input.addEventListener('input', (e) => this.formatPostalCode(e));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -341,16 +341,46 @@ class RepresentativesDisplay {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getShortAddress(address) {
|
getShortAddress(address) {
|
||||||
|
// Clean the address first
|
||||||
|
const cleaned = this.cleanAddress(address);
|
||||||
|
|
||||||
// Extract city and province/state for short display
|
// 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) {
|
if (parts.length >= 2) {
|
||||||
const city = parts[parts.length - 2].trim();
|
const city = parts[parts.length - 2];
|
||||||
const province = parts[parts.length - 1].trim();
|
const province = parts[parts.length - 1];
|
||||||
return `${city}, ${province}`;
|
return `${city}, ${province}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: just show first part
|
// Fallback: just show the cleaned address
|
||||||
return parts[0].trim();
|
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() {
|
attachEventListeners() {
|
||||||
@ -463,7 +493,7 @@ class RepresentativesDisplay {
|
|||||||
|
|
||||||
handleVisitClick(address, name, office) {
|
handleVisitClick(address, name, office) {
|
||||||
// Clean and format the address for URL encoding
|
// Clean and format the address for URL encoding
|
||||||
const cleanAddress = address.replace(/\n/g, ', ').trim();
|
const cleanAddress = this.cleanAddress(address);
|
||||||
|
|
||||||
// Show confirmation dialog
|
// Show confirmation dialog
|
||||||
const message = `Open directions to ${name}'s office?\n\nAddress: ${cleanAddress}`;
|
const message = `Open directions to ${name}'s office?\n\nAddress: ${cleanAddress}`;
|
||||||
|
|||||||
@ -85,7 +85,7 @@ function clearRepresentativeMarkers() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add representative offices to the map
|
// Add representative offices to the map
|
||||||
function displayRepresentativeOffices(representatives, postalCode) {
|
async function displayRepresentativeOffices(representatives, postalCode) {
|
||||||
// Initialize map if not already done
|
// Initialize map if not already done
|
||||||
if (!representativesMap) {
|
if (!representativesMap) {
|
||||||
console.log('Map not initialized, initializing now...');
|
console.log('Map not initialized, initializing now...');
|
||||||
@ -104,15 +104,19 @@ function displayRepresentativeOffices(representatives, postalCode) {
|
|||||||
let bounds = [];
|
let bounds = [];
|
||||||
|
|
||||||
console.log('Processing representatives for map display:', representatives.length);
|
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
|
// Group representatives by office location to handle shared addresses
|
||||||
const locationGroups = new Map();
|
const locationGroups = new Map();
|
||||||
|
|
||||||
representatives.forEach((rep, index) => {
|
// Process all representatives and geocode their offices
|
||||||
console.log(`Processing representative ${index + 1}:`, rep.name, rep.representative_set_name);
|
for (const rep of representatives) {
|
||||||
|
console.log(`Processing representative:`, rep.name, rep.representative_set_name);
|
||||||
|
|
||||||
// Try to get office location from various sources
|
// Get office location (now async for geocoding)
|
||||||
const offices = getOfficeLocations(rep);
|
const offices = await getOfficeLocations(rep);
|
||||||
console.log(`Found ${offices.length} offices for ${rep.name}:`, offices);
|
console.log(`Found ${offices.length} offices for ${rep.name}:`, offices);
|
||||||
|
|
||||||
offices.forEach((office, officeIndex) => {
|
offices.forEach((office, officeIndex) => {
|
||||||
@ -139,14 +143,22 @@ function displayRepresentativeOffices(representatives, postalCode) {
|
|||||||
console.log(`No coordinates found for ${rep.name} office:`, office);
|
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
|
// Create markers for each location group
|
||||||
let offsetIndex = 0;
|
let offsetIndex = 0;
|
||||||
locationGroups.forEach((locationGroup, locationKey) => {
|
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
|
// Single representative at this location
|
||||||
const rep = locationGroup.representatives[0];
|
const rep = locationGroup.representatives[0];
|
||||||
const office = locationGroup.offices[0];
|
const office = locationGroup.offices[0];
|
||||||
@ -157,35 +169,57 @@ function displayRepresentativeOffices(representatives, postalCode) {
|
|||||||
bounds.push([office.lat, office.lng]);
|
bounds.push([office.lat, office.lng]);
|
||||||
}
|
}
|
||||||
} else {
|
} 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) => {
|
locationGroup.representatives.forEach((rep, repIndex) => {
|
||||||
const office = locationGroup.offices[repIndex];
|
const office = locationGroup.offices[repIndex];
|
||||||
|
|
||||||
// Add small offset to avoid exact overlap
|
// Increase offset distance based on number of representatives
|
||||||
const offsetDistance = 0.0005; // About 50 meters
|
// More reps = larger circle for better visibility
|
||||||
const angle = (repIndex * 2 * Math.PI) / locationGroup.representatives.length;
|
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 offsetLat = office.lat + (offsetDistance * Math.cos(angle));
|
||||||
const offsetLng = office.lng + (offsetDistance * Math.sin(angle));
|
const offsetLng = office.lng + (offsetDistance * Math.sin(angle));
|
||||||
|
|
||||||
const offsetOffice = {
|
const offsetOffice = {
|
||||||
...office,
|
...office,
|
||||||
lat: offsetLat,
|
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}`);
|
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, locationGroup.representatives.length > 1);
|
const marker = createOfficeMarker(rep, offsetOffice, true);
|
||||||
if (marker) {
|
if (marker) {
|
||||||
representativeMarkers.push(marker);
|
representativeMarkers.push(marker);
|
||||||
marker.addTo(representativesMap);
|
marker.addTo(representativesMap);
|
||||||
bounds.push([offsetLat, offsetLng]);
|
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(`Total markers created: ${representativeMarkers.length}`);
|
||||||
|
console.log(`Unique locations: ${locationGroups.size}`);
|
||||||
console.log(`Bounds array:`, bounds);
|
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
|
// Fit map to show all offices, or center on Alberta if no offices found
|
||||||
if (bounds.length > 0) {
|
if (bounds.length > 0) {
|
||||||
@ -200,7 +234,7 @@ function displayRepresentativeOffices(representatives, postalCode) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract office locations from representative data
|
// Extract office locations from representative data
|
||||||
function getOfficeLocations(representative) {
|
async function getOfficeLocations(representative) {
|
||||||
const offices = [];
|
const offices = [];
|
||||||
|
|
||||||
console.log(`Getting office locations for ${representative.name}`);
|
console.log(`Getting office locations for ${representative.name}`);
|
||||||
@ -208,8 +242,8 @@ function getOfficeLocations(representative) {
|
|||||||
|
|
||||||
// Check various sources for office location data
|
// Check various sources for office location data
|
||||||
if (representative.offices && Array.isArray(representative.offices)) {
|
if (representative.offices && Array.isArray(representative.offices)) {
|
||||||
representative.offices.forEach((office, index) => {
|
for (const office of representative.offices) {
|
||||||
console.log(`Processing office ${index + 1}:`, office);
|
console.log(`Processing office:`, office);
|
||||||
|
|
||||||
// Use the 'postal' field which contains the address
|
// Use the 'postal' field which contains the address
|
||||||
if (office.postal || office.address) {
|
if (office.postal || office.address) {
|
||||||
@ -225,28 +259,49 @@ function getOfficeLocations(representative) {
|
|||||||
console.log('Created office data:', officeData);
|
console.log('Created office data:', officeData);
|
||||||
offices.push(officeData);
|
offices.push(officeData);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For all offices without coordinates, add approximate coordinates
|
// For all offices without coordinates, try to geocode the address
|
||||||
offices.forEach(office => {
|
for (const office of offices) {
|
||||||
if (!office.lat || !office.lng) {
|
if (!office.lat || !office.lng) {
|
||||||
console.log(`Adding coordinates to office for ${representative.name}`);
|
console.log(`Geocoding address for ${representative.name}: ${office.address}`);
|
||||||
const approxLocation = getApproximateLocationByDistrict(representative.district_name, representative.representative_set_name);
|
|
||||||
console.log('Approximate location:', approxLocation);
|
|
||||||
|
|
||||||
if (approxLocation) {
|
// Try geocoding the actual address first
|
||||||
office.lat = approxLocation.lat;
|
const geocoded = await geocodeWithRateLimit(office.address);
|
||||||
office.lng = approxLocation.lng;
|
|
||||||
console.log('Updated office with coordinates:', office);
|
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 approximate coordinates:', office);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
// If no offices found at all, create a fallback office
|
// If no offices found at all, create a fallback office
|
||||||
if (offices.length === 0 && representative.representative_set_name) {
|
if (offices.length === 0 && representative.representative_set_name) {
|
||||||
console.log(`No offices found, creating fallback office for ${representative.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);
|
console.log('Approximate location:', approxLocation);
|
||||||
|
|
||||||
if (approxLocation) {
|
if (approxLocation) {
|
||||||
@ -265,32 +320,308 @@ function getOfficeLocations(representative) {
|
|||||||
return offices;
|
return offices;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get approximate location based on district and government level
|
// Geocoding cache to avoid repeated API calls
|
||||||
function getApproximateLocationByDistrict(district, level) {
|
const geocodingCache = new Map();
|
||||||
// Specific locations for Edmonton officials
|
|
||||||
const edmontonLocations = {
|
// Clean and normalize address for geocoding
|
||||||
// City Hall for municipal officials
|
function normalizeAddressForGeocoding(address) {
|
||||||
'Edmonton': { lat: 53.5444, lng: -113.4909 }, // Edmonton City Hall
|
if (!address) return '';
|
||||||
"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 }
|
|
||||||
};
|
|
||||||
|
|
||||||
// Try specific district first
|
// Special handling for well-known government buildings
|
||||||
if (district && edmontonLocations[district]) {
|
const lowerAddress = address.toLowerCase();
|
||||||
return edmontonLocations[district];
|
|
||||||
|
// 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
|
// 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
|
||||||
|
};
|
||||||
|
|
||||||
|
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 = {
|
const levelLocations = {
|
||||||
'House of Commons': { lat: 53.5461, lng: -113.4938 }, // Downtown Edmonton
|
'House of Commons': albertaCityCoordinates['Ottawa'], // Federal = Ottawa
|
||||||
'Legislative Assembly of Alberta': { lat: 53.5344, lng: -113.5065 }, // Alberta Legislature
|
'Legislative Assembly of Alberta': { lat: 53.5344, lng: -113.5065 }, // Provincial = Legislature
|
||||||
'Edmonton City Council': { lat: 53.5444, lng: -113.4909 } // Edmonton City Hall
|
'Edmonton City Council': { lat: 53.5444, lng: -113.4909 } // Municipal = City Hall
|
||||||
};
|
};
|
||||||
|
|
||||||
return levelLocations[level] || { lat: 53.9333, lng: -116.5765 }; // Default to Alberta center
|
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
|
// 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 level = getRepresentativeLevel(representative.representative_set_name);
|
||||||
const levelClass = level.toLowerCase().replace(' ', '-');
|
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 `
|
return `
|
||||||
<div class="office-popup-content">
|
<div class="office-popup-content">
|
||||||
<div class="rep-header ${levelClass}">
|
<div class="rep-header ${levelClass}">
|
||||||
@ -334,13 +675,13 @@ function createOfficePopupContent(representative, office, isSharedLocation = fal
|
|||||||
<h4>${representative.name}</h4>
|
<h4>${representative.name}</h4>
|
||||||
<p class="rep-level">${level}</p>
|
<p class="rep-level">${level}</p>
|
||||||
<p class="rep-district">${representative.district_name || 'District not specified'}</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>
|
</div>
|
||||||
|
|
||||||
<div class="office-details">
|
<div class="office-details">
|
||||||
<h5>Office Information</h5>
|
<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.phone ? `<p><strong>Phone:</strong> <a href="tel:${office.phone}">${office.phone}</a></p>` : ''}
|
||||||
${office.fax ? `<p><strong>Fax:</strong> ${office.fax}</p>` : ''}
|
${office.fax ? `<p><strong>Fax:</strong> ${office.fax}</p>` : ''}
|
||||||
${office.postal_code ? `<p><strong>Postal Code:</strong> ${office.postal_code}</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 response = await fetch(`/api/representatives/by-postal/${normalizedPostalCode}`);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
hideLoading();
|
|
||||||
|
|
||||||
if (data.success && data.data && data.data.representatives) {
|
if (data.success && data.data && data.data.representatives) {
|
||||||
// Display representatives on map
|
// Display representatives on map (now async for geocoding)
|
||||||
displayRepresentativeOffices(data.data.representatives, normalizedPostalCode);
|
await displayRepresentativeOffices(data.data.representatives, normalizedPostalCode);
|
||||||
|
|
||||||
|
hideLoading();
|
||||||
|
|
||||||
// Also update the representatives display section using the existing system
|
// Also update the representatives display section using the existing system
|
||||||
if (window.representativesDisplay) {
|
if (window.representativesDisplay) {
|
||||||
@ -495,6 +836,7 @@ async function handlePostalCodeSubmission(postalCode) {
|
|||||||
window.messageDisplay.show(`Found ${data.data.representatives.length} representatives for ${normalizedPostalCode}`, 'success', 3000);
|
window.messageDisplay.show(`Found ${data.data.representatives.length} representatives for ${normalizedPostalCode}`, 'success', 3000);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
hideLoading();
|
||||||
showError(data.message || 'Unable to find representatives for this postal code.');
|
showError(data.message || 'Unable to find representatives for this postal code.');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
// Response Wall JavaScript
|
// Response Wall JavaScript
|
||||||
|
|
||||||
let currentCampaignSlug = null;
|
let currentCampaignSlug = null;
|
||||||
|
let currentCampaign = null;
|
||||||
let currentOffset = 0;
|
let currentOffset = 0;
|
||||||
let currentSort = 'recent';
|
let currentSort = 'recent';
|
||||||
let currentLevel = '';
|
let currentLevel = '';
|
||||||
const LIMIT = 20;
|
const LIMIT = 20;
|
||||||
|
let loadedRepresentatives = [];
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
@ -73,26 +75,514 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
form.addEventListener('submit', handleSubmitResponse);
|
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');
|
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() {
|
async function loadResponseStats() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/campaigns/${currentCampaignSlug}/response-stats`);
|
const data = await window.apiClient.get(`/campaigns/${currentCampaignSlug}/response-stats`);
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
|
// Store campaign data
|
||||||
|
currentCampaign = data.campaign;
|
||||||
|
|
||||||
|
// Update stats
|
||||||
document.getElementById('stat-total-responses').textContent = data.stats.totalResponses;
|
document.getElementById('stat-total-responses').textContent = data.stats.totalResponses;
|
||||||
document.getElementById('stat-verified').textContent = data.stats.verifiedResponses;
|
document.getElementById('stat-verified').textContent = data.stats.verifiedResponses;
|
||||||
document.getElementById('stat-upvotes').textContent = data.stats.totalUpvotes;
|
document.getElementById('stat-upvotes').textContent = data.stats.totalUpvotes;
|
||||||
document.getElementById('stats-banner').style.display = 'flex';
|
document.getElementById('stats-banner').style.display = 'flex';
|
||||||
|
|
||||||
|
// Render campaign header with campaign info
|
||||||
|
renderCampaignHeader();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading stats:', 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
|
// Load responses
|
||||||
async function loadResponses(reset = false) {
|
async function loadResponses(reset = false) {
|
||||||
if (reset) {
|
if (reset) {
|
||||||
@ -110,8 +600,7 @@ async function loadResponses(reset = false) {
|
|||||||
limit: LIMIT
|
limit: LIMIT
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await fetch(`/api/campaigns/${currentCampaignSlug}/responses?${params}`);
|
const data = await window.apiClient.get(`/campaigns/${currentCampaignSlug}/responses?${params}`);
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
showLoading(false);
|
showLoading(false);
|
||||||
|
|
||||||
@ -190,6 +679,18 @@ function createResponseCard(response) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const upvoteClass = response.hasUpvoted ? 'upvoted' : '';
|
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 = `
|
card.innerHTML = `
|
||||||
<div class="response-header">
|
<div class="response-header">
|
||||||
@ -220,6 +721,7 @@ function createResponseCard(response) {
|
|||||||
<span class="upvote-icon">👍</span>
|
<span class="upvote-icon">👍</span>
|
||||||
<span class="upvote-count">${response.upvote_count || 0}</span>
|
<span class="upvote-count">${response.upvote_count || 0}</span>
|
||||||
</button>
|
</button>
|
||||||
|
${verifyButtonHtml}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@ -229,6 +731,14 @@ function createResponseCard(response) {
|
|||||||
upvoteBtn.addEventListener('click', function() {
|
upvoteBtn.addEventListener('click', function() {
|
||||||
toggleUpvote(response.id, this);
|
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
|
// Add event listener for screenshot image if present
|
||||||
const screenshotImg = card.querySelector('.screenshot-image');
|
const screenshotImg = card.querySelector('.screenshot-image');
|
||||||
@ -244,17 +754,12 @@ function createResponseCard(response) {
|
|||||||
// Toggle upvote
|
// Toggle upvote
|
||||||
async function toggleUpvote(responseId, button) {
|
async function toggleUpvote(responseId, button) {
|
||||||
const isUpvoted = button.classList.contains('upvoted');
|
const isUpvoted = button.classList.contains('upvoted');
|
||||||
const url = `/api/responses/${responseId}/upvote`;
|
const url = `/responses/${responseId}/upvote`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, {
|
const data = isUpvoted
|
||||||
method: isUpvoted ? 'DELETE' : 'POST',
|
? await window.apiClient.delete(url)
|
||||||
headers: {
|
: await window.apiClient.post(url, {});
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
// Update button state
|
// Update button state
|
||||||
@ -294,6 +799,19 @@ function openSubmitModal() {
|
|||||||
function closeSubmitModal() {
|
function closeSubmitModal() {
|
||||||
document.getElementById('submit-modal').style.display = 'none';
|
document.getElementById('submit-modal').style.display = 'none';
|
||||||
document.getElementById('submit-response-form').reset();
|
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
|
// Handle response submission
|
||||||
@ -301,17 +819,25 @@ async function handleSubmitResponse(e) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const formData = new FormData(e.target);
|
const formData = new FormData(e.target);
|
||||||
|
|
||||||
|
// 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()
|
||||||
|
|
||||||
|
// Get verification status for UI feedback
|
||||||
|
const sendVerification = document.getElementById('send-verification').checked;
|
||||||
|
const repEmail = document.getElementById('representative-email').value;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/campaigns/${currentCampaignSlug}/responses`, {
|
const data = await window.apiClient.postFormData(`/campaigns/${currentCampaignSlug}/responses`, formData);
|
||||||
method: 'POST',
|
|
||||||
body: formData
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.success) {
|
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();
|
closeSubmitModal();
|
||||||
// Don't reload responses since submission is pending approval
|
// Don't reload responses since submission is pending approval
|
||||||
} else {
|
} 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
|
// View image in modal/new tab
|
||||||
function viewImage(url) {
|
function viewImage(url) {
|
||||||
window.open(url, '_blank');
|
window.open(url, '_blank');
|
||||||
|
|||||||
@ -8,12 +8,104 @@
|
|||||||
<link rel="stylesheet" href="/css/response-wall.css">
|
<link rel="stylesheet" href="/css/response-wall.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<!-- Campaign Header -->
|
||||||
<header>
|
<div class="response-wall-header">
|
||||||
|
<div class="response-wall-header-content">
|
||||||
<h1>📢 Community Response Wall</h1>
|
<h1>📢 Community Response Wall</h1>
|
||||||
<p>See what representatives are saying back to constituents</p>
|
<h2 id="campaign-name" class="campaign-subtitle" style="display: none;"></h2>
|
||||||
</header>
|
<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 -->
|
<!-- Stats Banner -->
|
||||||
<div class="stats-banner" id="stats-banner" style="display: none;">
|
<div class="stats-banner" id="stats-banner" style="display: none;">
|
||||||
<div class="stat-item">
|
<div class="stat-item">
|
||||||
@ -83,9 +175,30 @@
|
|||||||
<span class="close" id="modal-close-btn">×</span>
|
<span class="close" id="modal-close-btn">×</span>
|
||||||
<h2>Share a Representative Response</h2>
|
<h2>Share a Representative Response</h2>
|
||||||
<form id="submit-response-form" enctype="multipart/form-data">
|
<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">
|
<div class="form-group">
|
||||||
<label for="representative-name">Representative Name *</label>
|
<label for="representative-name">Representative Name *</label>
|
||||||
<input type="text" id="representative-name" name="representative_name" required>
|
<input type="text" id="representative-name" name="representative_name" required>
|
||||||
|
<small>Or enter manually if not found above</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@ -104,6 +217,9 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Hidden field to store representative email for verification -->
|
||||||
|
<input type="hidden" id="representative-email" name="representative_email">
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="response-type">Response Type *</label>
|
<label for="response-type">Response Type *</label>
|
||||||
<select id="response-type" name="response_type" required>
|
<select id="response-type" name="response_type" required>
|
||||||
@ -150,6 +266,14 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</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">
|
<div class="form-actions">
|
||||||
<button type="button" class="btn btn-secondary" id="cancel-submit-btn">Cancel</button>
|
<button type="button" class="btn btn-secondary" id="cancel-submit-btn">Cancel</button>
|
||||||
<button type="submit" class="btn btn-primary">Submit Response</button>
|
<button type="submit" class="btn btn-primary">Submit Response</button>
|
||||||
@ -158,6 +282,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- QR Code Modal -->
|
||||||
|
<div id="qrcode-modal" class="qrcode-modal">
|
||||||
|
<div class="qrcode-modal-content">
|
||||||
|
<span class="qrcode-close">×</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>
|
<script src="/js/response-wall.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -4,6 +4,7 @@ const { body, param, validationResult } = require('express-validator');
|
|||||||
const representativesController = require('../controllers/representatives');
|
const representativesController = require('../controllers/representatives');
|
||||||
const emailsController = require('../controllers/emails');
|
const emailsController = require('../controllers/emails');
|
||||||
const campaignsController = require('../controllers/campaigns');
|
const campaignsController = require('../controllers/campaigns');
|
||||||
|
const customRecipientsController = require('../controllers/customRecipients');
|
||||||
const responsesController = require('../controllers/responses');
|
const responsesController = require('../controllers/responses');
|
||||||
const rateLimiter = require('../utils/rate-limiter');
|
const rateLimiter = require('../utils/rate-limiter');
|
||||||
const { requireAdmin, requireAuth, requireNonTemp, optionalAuth } = require('../middleware/auth');
|
const { requireAdmin, requireAuth, requireNonTemp, optionalAuth } = require('../middleware/auth');
|
||||||
@ -12,6 +13,9 @@ const upload = require('../middleware/upload');
|
|||||||
// Import user routes
|
// Import user routes
|
||||||
const userRoutes = require('./users');
|
const userRoutes = require('./users');
|
||||||
|
|
||||||
|
// Import Listmonk routes
|
||||||
|
const listmonkRoutes = require('./listmonk');
|
||||||
|
|
||||||
// Validation middleware
|
// Validation middleware
|
||||||
const handleValidationErrors = (req, res, next) => {
|
const handleValidationErrors = (req, res, next) => {
|
||||||
const errors = validationResult(req);
|
const errors = validationResult(req);
|
||||||
@ -45,6 +49,17 @@ router.post(
|
|||||||
representativesController.refreshPostalCode
|
representativesController.refreshPostalCode
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Geocoding endpoint (proxy to Nominatim)
|
||||||
|
router.post(
|
||||||
|
'/geocode',
|
||||||
|
rateLimiter.general,
|
||||||
|
[
|
||||||
|
body('address').notEmpty().withMessage('Address is required')
|
||||||
|
],
|
||||||
|
handleValidationErrors,
|
||||||
|
representativesController.geocodeAddress
|
||||||
|
);
|
||||||
|
|
||||||
// Email endpoints
|
// Email endpoints
|
||||||
router.post(
|
router.post(
|
||||||
'/emails/preview',
|
'/emails/preview',
|
||||||
@ -163,7 +178,9 @@ router.get('/campaigns/:id/analytics', requireAuth, rateLimiter.general, campaig
|
|||||||
|
|
||||||
// Campaign endpoints (Public)
|
// Campaign endpoints (Public)
|
||||||
router.get('/public/campaigns', rateLimiter.general, campaignsController.getPublicCampaigns);
|
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', rateLimiter.general, campaignsController.getCampaignBySlug);
|
||||||
|
router.get('/campaigns/:slug/qrcode', rateLimiter.general, campaignsController.generateQRCode);
|
||||||
router.get('/campaigns/:slug/representatives/:postalCode', rateLimiter.representAPI, campaignsController.getRepresentativesForCampaign);
|
router.get('/campaigns/:slug/representatives/:postalCode', rateLimiter.representAPI, campaignsController.getRepresentativesForCampaign);
|
||||||
router.post(
|
router.post(
|
||||||
'/campaigns/:slug/track-user',
|
'/campaigns/:slug/track-user',
|
||||||
@ -211,9 +228,66 @@ router.post(
|
|||||||
representativesController.trackCall
|
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)
|
// User management routes (admin only)
|
||||||
router.use('/admin/users', userRoutes);
|
router.use('/admin/users', userRoutes);
|
||||||
|
|
||||||
|
// Listmonk email sync routes (admin only)
|
||||||
|
router.use('/listmonk', listmonkRoutes);
|
||||||
|
|
||||||
// Response Wall Routes
|
// Response Wall Routes
|
||||||
router.get('/campaigns/:slug/responses', rateLimiter.general, responsesController.getCampaignResponses);
|
router.get('/campaigns/:slug/responses', rateLimiter.general, responsesController.getCampaignResponses);
|
||||||
router.get('/campaigns/:slug/response-stats', rateLimiter.general, responsesController.getResponseStats);
|
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.post('/responses/:id/upvote', optionalAuth, rateLimiter.general, responsesController.upvoteResponse);
|
||||||
router.delete('/responses/:id/upvote', optionalAuth, rateLimiter.general, responsesController.removeUpvote);
|
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
|
// Admin and Campaign Owner Response Management Routes
|
||||||
router.get('/admin/responses', requireNonTemp, rateLimiter.general, responsesController.getAdminResponses);
|
router.get('/admin/responses', requireNonTemp, rateLimiter.general, responsesController.getAdminResponses);
|
||||||
router.patch('/admin/responses/:id/status', requireNonTemp, rateLimiter.general,
|
router.patch('/admin/responses/:id/status', requireNonTemp, rateLimiter.general,
|
||||||
|
|||||||
26
influence/app/routes/listmonk.js
Normal 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;
|
||||||
@ -2,9 +2,16 @@ const express = require('express');
|
|||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const helmet = require('helmet');
|
const helmet = require('helmet');
|
||||||
const session = require('express-session');
|
const session = require('express-session');
|
||||||
|
const compression = require('compression');
|
||||||
|
const cookieParser = require('cookie-parser');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const logger = require('./utils/logger');
|
||||||
|
const metrics = require('./utils/metrics');
|
||||||
|
const healthCheck = require('./utils/health-check');
|
||||||
|
const { conditionalCsrfProtection, getCsrfToken, csrfProtection } = require('./middleware/csrf');
|
||||||
|
|
||||||
const apiRoutes = require('./routes/api');
|
const apiRoutes = require('./routes/api');
|
||||||
const authRoutes = require('./routes/auth');
|
const authRoutes = require('./routes/auth');
|
||||||
const { requireAdmin, requireAuth } = require('./middleware/auth');
|
const { requireAdmin, requireAuth } = require('./middleware/auth');
|
||||||
@ -16,6 +23,17 @@ const PORT = process.env.PORT || 3333;
|
|||||||
// Only trust Docker internal networks for better security
|
// Only trust Docker internal networks for better security
|
||||||
app.set('trust proxy', ['127.0.0.1', '::1', '172.16.0.0/12', '192.168.0.0/16', '10.0.0.0/8']);
|
app.set('trust proxy', ['127.0.0.1', '::1', '172.16.0.0/12', '192.168.0.0/16', '10.0.0.0/8']);
|
||||||
|
|
||||||
|
// Compression middleware for better performance
|
||||||
|
app.use(compression({
|
||||||
|
filter: (req, res) => {
|
||||||
|
if (req.headers['x-no-compression']) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return compression.filter(req, res);
|
||||||
|
},
|
||||||
|
level: 6 // Balance between speed and compression ratio
|
||||||
|
}));
|
||||||
|
|
||||||
// Security middleware
|
// Security middleware
|
||||||
app.use(helmet({
|
app.use(helmet({
|
||||||
contentSecurityPolicy: {
|
contentSecurityPolicy: {
|
||||||
@ -30,27 +48,91 @@ app.use(helmet({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
app.use(cors());
|
// CORS configuration - Allow credentials for cookie-based CSRF
|
||||||
app.use(express.json());
|
app.use(cors({
|
||||||
app.use(express.urlencoded({ extended: true }));
|
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({
|
app.use(session({
|
||||||
secret: process.env.SESSION_SECRET || 'influence-campaign-secret-key-change-in-production',
|
secret: process.env.SESSION_SECRET || 'influence-campaign-secret-key-change-in-production',
|
||||||
resave: false,
|
resave: false,
|
||||||
saveUninitialized: false,
|
saveUninitialized: false,
|
||||||
|
name: 'influence.sid', // Custom session name for security
|
||||||
cookie: {
|
cookie: {
|
||||||
secure: process.env.NODE_ENV === 'production' && process.env.HTTPS === 'true',
|
secure: process.env.NODE_ENV === 'production' && process.env.HTTPS === 'true', // HTTPS only in production
|
||||||
httpOnly: true,
|
httpOnly: true, // Prevent JavaScript access
|
||||||
maxAge: 24 * 60 * 60 * 1000 // 24 hours
|
maxAge: 3600000, // 1 hour (reduced from 24 hours)
|
||||||
|
sameSite: 'strict' // CSRF protection
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
app.use(express.static(path.join(__dirname, 'public')));
|
// CSRF Protection - Applied conditionally
|
||||||
|
app.use(conditionalCsrfProtection);
|
||||||
|
|
||||||
// Routes
|
// Static files with proper caching
|
||||||
app.use('/api/auth', authRoutes);
|
app.use(express.static(path.join(__dirname, 'public'), {
|
||||||
app.use('/api', apiRoutes);
|
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
|
// Config endpoint - expose APP_URL to client
|
||||||
app.get('/api/config', (req, res) => {
|
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
|
// Serve the main page
|
||||||
app.get('/', (req, res) => {
|
app.get('/', (req, res) => {
|
||||||
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
||||||
@ -100,21 +196,54 @@ app.get('/campaign/:slug', (req, res) => {
|
|||||||
|
|
||||||
// Error handling middleware
|
// Error handling middleware
|
||||||
app.use((err, req, res, next) => {
|
app.use((err, req, res, next) => {
|
||||||
console.error(err.stack);
|
logger.error('Application error', {
|
||||||
res.status(500).json({
|
error: err.message,
|
||||||
|
stack: err.stack,
|
||||||
|
path: req.path,
|
||||||
|
method: req.method,
|
||||||
|
ip: req.ip
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(err.status || 500).json({
|
||||||
error: 'Something went wrong!',
|
error: 'Something went wrong!',
|
||||||
message: process.env.NODE_ENV === 'development' ? err.message : 'Internal server error'
|
message: process.env.NODE_ENV === 'development' ? err.message : 'Internal server error',
|
||||||
|
...(process.env.NODE_ENV === 'development' && { stack: err.stack })
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 404 handler
|
// 404 handler
|
||||||
app.use((req, res) => {
|
app.use((req, res) => {
|
||||||
|
logger.warn('Route not found', {
|
||||||
|
path: req.path,
|
||||||
|
method: req.method,
|
||||||
|
ip: req.ip
|
||||||
|
});
|
||||||
res.status(404).json({ error: 'Route not found' });
|
res.status(404).json({ error: 'Route not found' });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
// Graceful shutdown
|
||||||
console.log(`Server running on port ${PORT}`);
|
process.on('SIGTERM', () => {
|
||||||
console.log(`Environment: ${process.env.NODE_ENV}`);
|
logger.info('SIGTERM received, shutting down gracefully');
|
||||||
|
server.close(() => {
|
||||||
|
logger.info('Server closed');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
logger.info('SIGINT received, shutting down gracefully');
|
||||||
|
server.close(() => {
|
||||||
|
logger.info('Server closed');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const server = app.listen(PORT, () => {
|
||||||
|
logger.info('Server started', {
|
||||||
|
port: PORT,
|
||||||
|
environment: process.env.NODE_ENV,
|
||||||
|
nodeVersion: process.version
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = app;
|
module.exports = app;
|
||||||
@ -409,6 +409,62 @@ class EmailService {
|
|||||||
throw error;
|
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();
|
module.exports = new EmailService();
|
||||||
368
influence/app/services/emailQueue.js
Normal file
@ -0,0 +1,368 @@
|
|||||||
|
const Queue = require('bull');
|
||||||
|
const logger = require('./logger');
|
||||||
|
const metrics = require('./metrics');
|
||||||
|
const emailService = require('../services/email');
|
||||||
|
|
||||||
|
// Configure Redis connection for Bull
|
||||||
|
const redisConfig = {
|
||||||
|
host: process.env.REDIS_HOST || 'localhost',
|
||||||
|
port: parseInt(process.env.REDIS_PORT || '6379'),
|
||||||
|
password: process.env.REDIS_PASSWORD || undefined,
|
||||||
|
db: parseInt(process.env.REDIS_DB || '0'),
|
||||||
|
// Retry strategy for connection failures
|
||||||
|
retryStrategy: (times) => {
|
||||||
|
const delay = Math.min(times * 50, 2000);
|
||||||
|
return delay;
|
||||||
|
},
|
||||||
|
// Enable offline queue
|
||||||
|
enableOfflineQueue: true,
|
||||||
|
maxRetriesPerRequest: 3
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create email queue
|
||||||
|
const emailQueue = new Queue('email-queue', {
|
||||||
|
redis: redisConfig,
|
||||||
|
defaultJobOptions: {
|
||||||
|
attempts: 3,
|
||||||
|
backoff: {
|
||||||
|
type: 'exponential',
|
||||||
|
delay: 2000 // Start with 2 seconds, then 4, 8, etc.
|
||||||
|
},
|
||||||
|
removeOnComplete: {
|
||||||
|
age: 24 * 3600, // Keep completed jobs for 24 hours
|
||||||
|
count: 1000 // Keep last 1000 completed jobs
|
||||||
|
},
|
||||||
|
removeOnFail: {
|
||||||
|
age: 7 * 24 * 3600 // Keep failed jobs for 7 days
|
||||||
|
},
|
||||||
|
timeout: 30000 // 30 seconds timeout per job
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Process email jobs
|
||||||
|
emailQueue.process(async (job) => {
|
||||||
|
const { type, data } = job.data;
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
logger.info('Processing email job', {
|
||||||
|
jobId: job.id,
|
||||||
|
type,
|
||||||
|
attempt: job.attemptsMade + 1,
|
||||||
|
maxAttempts: job.opts.attempts
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
let result;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'campaign':
|
||||||
|
result = await emailService.sendCampaignEmail(data);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'verification':
|
||||||
|
result = await emailService.sendVerificationEmail(data);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'login-details':
|
||||||
|
result = await emailService.sendLoginDetails(data);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'broadcast':
|
||||||
|
result = await emailService.sendBroadcast(data);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'response-verification':
|
||||||
|
result = await emailService.sendResponseVerificationEmail(data);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown email type: ${type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration = (Date.now() - start) / 1000;
|
||||||
|
|
||||||
|
// Record metrics
|
||||||
|
metrics.recordEmailSent(
|
||||||
|
data.campaignId || 'system',
|
||||||
|
data.representativeLevel || 'unknown'
|
||||||
|
);
|
||||||
|
metrics.observeEmailSendDuration(
|
||||||
|
data.campaignId || 'system',
|
||||||
|
duration
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.logEmailSent(
|
||||||
|
data.to || data.email,
|
||||||
|
data.campaignId || type,
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const duration = (Date.now() - start) / 1000;
|
||||||
|
|
||||||
|
// Record failure metrics
|
||||||
|
metrics.recordEmailFailed(
|
||||||
|
data.campaignId || 'system',
|
||||||
|
error.code || 'unknown'
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.logEmailFailed(
|
||||||
|
data.to || data.email,
|
||||||
|
data.campaignId || type,
|
||||||
|
error
|
||||||
|
);
|
||||||
|
|
||||||
|
// Throw error to trigger retry
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Queue event handlers
|
||||||
|
emailQueue.on('completed', (job, result) => {
|
||||||
|
logger.info('Email job completed', {
|
||||||
|
jobId: job.id,
|
||||||
|
type: job.data.type,
|
||||||
|
duration: Date.now() - job.timestamp
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
emailQueue.on('failed', (job, err) => {
|
||||||
|
logger.error('Email job failed', {
|
||||||
|
jobId: job.id,
|
||||||
|
type: job.data.type,
|
||||||
|
attempt: job.attemptsMade,
|
||||||
|
maxAttempts: job.opts.attempts,
|
||||||
|
error: err.message,
|
||||||
|
willRetry: job.attemptsMade < job.opts.attempts
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
emailQueue.on('stalled', (job) => {
|
||||||
|
logger.warn('Email job stalled', {
|
||||||
|
jobId: job.id,
|
||||||
|
type: job.data.type
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
emailQueue.on('error', (error) => {
|
||||||
|
logger.error('Email queue error', { error: error.message });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update queue size metric every 10 seconds
|
||||||
|
setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const counts = await emailQueue.getJobCounts();
|
||||||
|
const queueSize = counts.waiting + counts.active;
|
||||||
|
metrics.setEmailQueueSize(queueSize);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Failed to update queue metrics', { error: error.message });
|
||||||
|
}
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Email Queue Service
|
||||||
|
* Provides methods to enqueue different types of emails
|
||||||
|
*/
|
||||||
|
class EmailQueueService {
|
||||||
|
/**
|
||||||
|
* Send campaign email (to representative)
|
||||||
|
*/
|
||||||
|
async sendCampaignEmail(emailData) {
|
||||||
|
const job = await emailQueue.add(
|
||||||
|
{
|
||||||
|
type: 'campaign',
|
||||||
|
data: emailData
|
||||||
|
},
|
||||||
|
{
|
||||||
|
priority: 2, // Normal priority
|
||||||
|
jobId: `campaign-${emailData.campaignId}-${Date.now()}`
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('Campaign email queued', {
|
||||||
|
jobId: job.id,
|
||||||
|
campaignId: emailData.campaignId,
|
||||||
|
recipient: emailData.to
|
||||||
|
});
|
||||||
|
|
||||||
|
return { jobId: job.id, queued: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send email verification
|
||||||
|
*/
|
||||||
|
async sendVerificationEmail(emailData) {
|
||||||
|
const job = await emailQueue.add(
|
||||||
|
{
|
||||||
|
type: 'verification',
|
||||||
|
data: emailData
|
||||||
|
},
|
||||||
|
{
|
||||||
|
priority: 1, // High priority - user waiting
|
||||||
|
jobId: `verification-${emailData.email}-${Date.now()}`
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('Verification email queued', {
|
||||||
|
jobId: job.id,
|
||||||
|
email: emailData.email
|
||||||
|
});
|
||||||
|
|
||||||
|
return { jobId: job.id, queued: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send login details
|
||||||
|
*/
|
||||||
|
async sendLoginDetails(emailData) {
|
||||||
|
const job = await emailQueue.add(
|
||||||
|
{
|
||||||
|
type: 'login-details',
|
||||||
|
data: emailData
|
||||||
|
},
|
||||||
|
{
|
||||||
|
priority: 1, // High priority
|
||||||
|
jobId: `login-${emailData.email}-${Date.now()}`
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('Login details email queued', {
|
||||||
|
jobId: job.id,
|
||||||
|
email: emailData.email
|
||||||
|
});
|
||||||
|
|
||||||
|
return { jobId: job.id, queued: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send broadcast to users
|
||||||
|
*/
|
||||||
|
async sendBroadcast(emailData) {
|
||||||
|
const job = await emailQueue.add(
|
||||||
|
{
|
||||||
|
type: 'broadcast',
|
||||||
|
data: emailData
|
||||||
|
},
|
||||||
|
{
|
||||||
|
priority: 3, // Lower priority - batch operation
|
||||||
|
jobId: `broadcast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('Broadcast email queued', {
|
||||||
|
jobId: job.id,
|
||||||
|
recipientCount: emailData.recipients?.length || 1
|
||||||
|
});
|
||||||
|
|
||||||
|
return { jobId: job.id, queued: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send response verification email
|
||||||
|
*/
|
||||||
|
async sendResponseVerificationEmail(emailData) {
|
||||||
|
const job = await emailQueue.add(
|
||||||
|
{
|
||||||
|
type: 'response-verification',
|
||||||
|
data: emailData
|
||||||
|
},
|
||||||
|
{
|
||||||
|
priority: 2,
|
||||||
|
jobId: `response-verification-${emailData.email}-${Date.now()}`
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.info('Response verification email queued', {
|
||||||
|
jobId: job.id,
|
||||||
|
email: emailData.email
|
||||||
|
});
|
||||||
|
|
||||||
|
return { jobId: job.id, queued: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get job status
|
||||||
|
*/
|
||||||
|
async getJobStatus(jobId) {
|
||||||
|
const job = await emailQueue.getJob(jobId);
|
||||||
|
|
||||||
|
if (!job) {
|
||||||
|
return { status: 'not_found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = await job.getState();
|
||||||
|
const progress = job.progress();
|
||||||
|
|
||||||
|
return {
|
||||||
|
jobId: job.id,
|
||||||
|
status: state,
|
||||||
|
progress,
|
||||||
|
attempts: job.attemptsMade,
|
||||||
|
data: job.data,
|
||||||
|
createdAt: job.timestamp,
|
||||||
|
processedAt: job.processedOn,
|
||||||
|
finishedAt: job.finishedOn,
|
||||||
|
failedReason: job.failedReason
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get queue statistics
|
||||||
|
*/
|
||||||
|
async getQueueStats() {
|
||||||
|
const counts = await emailQueue.getJobCounts();
|
||||||
|
const jobs = {
|
||||||
|
waiting: await emailQueue.getWaiting(0, 10),
|
||||||
|
active: await emailQueue.getActive(0, 10),
|
||||||
|
completed: await emailQueue.getCompleted(0, 10),
|
||||||
|
failed: await emailQueue.getFailed(0, 10)
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
counts,
|
||||||
|
samples: {
|
||||||
|
waiting: jobs.waiting.map(j => ({ id: j.id, type: j.data.type })),
|
||||||
|
active: jobs.active.map(j => ({ id: j.id, type: j.data.type })),
|
||||||
|
completed: jobs.completed.slice(0, 5).map(j => ({ id: j.id, type: j.data.type })),
|
||||||
|
failed: jobs.failed.slice(0, 5).map(j => ({ id: j.id, type: j.data.type, reason: j.failedReason }))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean old jobs
|
||||||
|
*/
|
||||||
|
async cleanQueue(grace = 24 * 3600 * 1000) {
|
||||||
|
const cleaned = await emailQueue.clean(grace, 'completed');
|
||||||
|
logger.info('Queue cleaned', { removedJobs: cleaned.length });
|
||||||
|
return { cleaned: cleaned.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause queue
|
||||||
|
*/
|
||||||
|
async pauseQueue() {
|
||||||
|
await emailQueue.pause();
|
||||||
|
logger.warn('Email queue paused');
|
||||||
|
return { paused: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resume queue
|
||||||
|
*/
|
||||||
|
async resumeQueue() {
|
||||||
|
await emailQueue.resume();
|
||||||
|
logger.info('Email queue resumed');
|
||||||
|
return { resumed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get queue instance (for advanced operations)
|
||||||
|
*/
|
||||||
|
getQueue() {
|
||||||
|
return emailQueue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new EmailQueueService();
|
||||||
832
influence/app/services/listmonk.js
Normal 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;
|
||||||
@ -29,7 +29,8 @@ class NocoDBService {
|
|||||||
calls: process.env.NOCODB_TABLE_CALLS,
|
calls: process.env.NOCODB_TABLE_CALLS,
|
||||||
representativeResponses: process.env.NOCODB_TABLE_REPRESENTATIVE_RESPONSES,
|
representativeResponses: process.env.NOCODB_TABLE_REPRESENTATIVE_RESPONSES,
|
||||||
responseUpvotes: process.env.NOCODB_TABLE_RESPONSE_UPVOTES,
|
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
|
// Validate that all table IDs are set
|
||||||
@ -437,7 +438,9 @@ class NocoDBService {
|
|||||||
'Collect User Info': campaignData.collect_user_info,
|
'Collect User Info': campaignData.collect_user_info,
|
||||||
'Show Email Count': campaignData.show_email_count,
|
'Show Email Count': campaignData.show_email_count,
|
||||||
'Allow Email Editing': campaignData.allow_email_editing,
|
'Allow Email Editing': campaignData.allow_email_editing,
|
||||||
|
'Allow Custom Recipients': campaignData.allow_custom_recipients,
|
||||||
'Show Response Wall Button': campaignData.show_response_wall,
|
'Show Response Wall Button': campaignData.show_response_wall,
|
||||||
|
'Highlight Campaign': campaignData.highlight_campaign,
|
||||||
'Target Government Levels': campaignData.target_government_levels,
|
'Target Government Levels': campaignData.target_government_levels,
|
||||||
'Created By User ID': campaignData.created_by_user_id,
|
'Created By User ID': campaignData.created_by_user_id,
|
||||||
'Created By User Email': campaignData.created_by_user_email,
|
'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.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.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_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.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.target_government_levels !== undefined) mappedUpdates['Target Government Levels'] = updates.target_government_levels;
|
||||||
if (updates.updated_at !== undefined) mappedUpdates['UpdatedAt'] = updates.updated_at;
|
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
|
// Campaign email tracking methods
|
||||||
async logCampaignEmail(emailData) {
|
async logCampaignEmail(emailData) {
|
||||||
try {
|
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) {
|
async getCampaignAnalytics(campaignId) {
|
||||||
try {
|
try {
|
||||||
const response = await this.getAll(this.tableIds.campaignEmails, {
|
const response = await this.getAll(this.tableIds.campaignEmails, {
|
||||||
@ -758,6 +830,11 @@ class NocoDBService {
|
|||||||
'Is Anonymous': responseData.is_anonymous,
|
'Is Anonymous': responseData.is_anonymous,
|
||||||
'Status': responseData.status,
|
'Status': responseData.status,
|
||||||
'Is Verified': responseData.is_verified,
|
'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,
|
'Upvote Count': responseData.upvote_count,
|
||||||
'Submitted IP': responseData.submitted_ip
|
'Submitted IP': responseData.submitted_ip
|
||||||
};
|
};
|
||||||
@ -780,6 +857,11 @@ class NocoDBService {
|
|||||||
if (updates.upvote_count !== undefined) data['Upvote Count'] = updates.upvote_count;
|
if (updates.upvote_count !== undefined) data['Upvote Count'] = updates.upvote_count;
|
||||||
if (updates.response_text !== undefined) data['Response Text'] = updates.response_text;
|
if (updates.response_text !== undefined) data['Response Text'] = updates.response_text;
|
||||||
if (updates.user_comment !== undefined) data['User Comment'] = updates.user_comment;
|
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));
|
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,
|
is_anonymous: data['Is Anonymous'] || data.is_anonymous || false,
|
||||||
status: data['Status'] || data.status,
|
status: data['Status'] || data.status,
|
||||||
is_verified: data['Is Verified'] || data.is_verified || false,
|
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,
|
upvote_count: data['Upvote Count'] || data.upvote_count || 0,
|
||||||
submitted_ip: data['Submitted IP'] || data.submitted_ip,
|
submitted_ip: data['Submitted IP'] || data.submitted_ip,
|
||||||
created_at: data.CreatedAt || data.created_at,
|
created_at: data.CreatedAt || data.created_at,
|
||||||
@ -955,6 +1042,212 @@ class NocoDBService {
|
|||||||
return { success: false, error: error.message };
|
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();
|
module.exports = new NocoDBService();
|
||||||
|
|||||||
152
influence/app/services/qrcode.js
Normal 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
|
||||||
|
};
|
||||||
155
influence/app/templates/email/response-verification.html
Normal 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>
|
||||||
42
influence/app/templates/email/response-verification.txt
Normal 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}}
|
||||||
350
influence/app/utils/analytics.js
Normal file
@ -0,0 +1,350 @@
|
|||||||
|
const logger = require('./logger');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analytics tracking utilities for the Influence application
|
||||||
|
* Provides helpers for tracking campaign conversion, representative response rates,
|
||||||
|
* user retention, and geographic participation
|
||||||
|
*/
|
||||||
|
class Analytics {
|
||||||
|
constructor() {
|
||||||
|
this.cache = {
|
||||||
|
campaignStats: new Map(),
|
||||||
|
userRetention: new Map(),
|
||||||
|
geographicData: new Map()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track campaign conversion rate
|
||||||
|
* @param {string} campaignId - Campaign identifier
|
||||||
|
* @param {number} visitors - Number of unique visitors
|
||||||
|
* @param {number} participants - Number of participants who took action
|
||||||
|
*/
|
||||||
|
trackCampaignConversion(campaignId, visitors, participants) {
|
||||||
|
const conversionRate = visitors > 0 ? (participants / visitors) * 100 : 0;
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
campaignId,
|
||||||
|
visitors,
|
||||||
|
participants,
|
||||||
|
conversionRate: conversionRate.toFixed(2),
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
this.cache.campaignStats.set(campaignId, stats);
|
||||||
|
|
||||||
|
logger.info('Campaign conversion tracked', {
|
||||||
|
event: 'analytics_campaign_conversion',
|
||||||
|
...stats
|
||||||
|
});
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate representative response rate
|
||||||
|
* @param {string} representativeLevel - Level (Federal, Provincial, Municipal)
|
||||||
|
* @param {number} emailsSent - Total emails sent
|
||||||
|
* @param {number} responsesReceived - Total responses received
|
||||||
|
*/
|
||||||
|
calculateRepresentativeResponseRate(representativeLevel, emailsSent, responsesReceived) {
|
||||||
|
const responseRate = emailsSent > 0 ? (responsesReceived / emailsSent) * 100 : 0;
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
representativeLevel,
|
||||||
|
emailsSent,
|
||||||
|
responsesReceived,
|
||||||
|
responseRate: responseRate.toFixed(2),
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info('Representative response rate calculated', {
|
||||||
|
event: 'analytics_response_rate',
|
||||||
|
...stats
|
||||||
|
});
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track user retention
|
||||||
|
* @param {string} userId - User identifier
|
||||||
|
* @param {string} action - Action type (login, campaign_participation, etc.)
|
||||||
|
*/
|
||||||
|
trackUserRetention(userId, action) {
|
||||||
|
let userData = this.cache.userRetention.get(userId) || {
|
||||||
|
userId,
|
||||||
|
firstSeen: new Date().toISOString(),
|
||||||
|
lastSeen: new Date().toISOString(),
|
||||||
|
actionCount: 0,
|
||||||
|
actions: []
|
||||||
|
};
|
||||||
|
|
||||||
|
userData.lastSeen = new Date().toISOString();
|
||||||
|
userData.actionCount += 1;
|
||||||
|
userData.actions.push({
|
||||||
|
action,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep only last 100 actions per user
|
||||||
|
if (userData.actions.length > 100) {
|
||||||
|
userData.actions = userData.actions.slice(-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cache.userRetention.set(userId, userData);
|
||||||
|
|
||||||
|
// Determine if user is one-time or repeat
|
||||||
|
const daysBetween = this.getDaysBetween(userData.firstSeen, userData.lastSeen);
|
||||||
|
const isRepeatUser = daysBetween > 0 || userData.actionCount > 1;
|
||||||
|
|
||||||
|
logger.info('User retention tracked', {
|
||||||
|
event: 'analytics_user_retention',
|
||||||
|
userId,
|
||||||
|
action,
|
||||||
|
isRepeatUser,
|
||||||
|
totalActions: userData.actionCount
|
||||||
|
});
|
||||||
|
|
||||||
|
return { ...userData, isRepeatUser };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track geographic participation by postal code
|
||||||
|
* @param {string} postalCode - Canadian postal code
|
||||||
|
* @param {string} campaignId - Campaign identifier
|
||||||
|
*/
|
||||||
|
trackGeographicParticipation(postalCode, campaignId) {
|
||||||
|
const postalPrefix = postalCode.substring(0, 3).toUpperCase();
|
||||||
|
|
||||||
|
let geoData = this.cache.geographicData.get(postalPrefix) || {
|
||||||
|
postalPrefix,
|
||||||
|
participationCount: 0,
|
||||||
|
campaigns: new Set()
|
||||||
|
};
|
||||||
|
|
||||||
|
geoData.participationCount += 1;
|
||||||
|
geoData.campaigns.add(campaignId);
|
||||||
|
|
||||||
|
this.cache.geographicData.set(postalPrefix, geoData);
|
||||||
|
|
||||||
|
logger.info('Geographic participation tracked', {
|
||||||
|
event: 'analytics_geographic_participation',
|
||||||
|
postalPrefix,
|
||||||
|
campaignId,
|
||||||
|
totalParticipation: geoData.participationCount
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
postalPrefix,
|
||||||
|
participationCount: geoData.participationCount,
|
||||||
|
uniqueCampaigns: geoData.campaigns.size
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get geographic heatmap data
|
||||||
|
* @returns {Array} Array of postal code prefixes with participation counts
|
||||||
|
*/
|
||||||
|
getGeographicHeatmap() {
|
||||||
|
const heatmapData = [];
|
||||||
|
|
||||||
|
for (const [postalPrefix, data] of this.cache.geographicData.entries()) {
|
||||||
|
heatmapData.push({
|
||||||
|
postalPrefix,
|
||||||
|
participationCount: data.participationCount,
|
||||||
|
uniqueCampaigns: data.campaigns.size
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return heatmapData.sort((a, b) => b.participationCount - a.participationCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Analyze peak usage times
|
||||||
|
* @param {Array} events - Array of event objects with timestamps
|
||||||
|
* @returns {Object} Peak usage analysis
|
||||||
|
*/
|
||||||
|
analyzePeakUsageTimes(events) {
|
||||||
|
const hourCounts = new Array(24).fill(0);
|
||||||
|
const dayOfWeekCounts = new Array(7).fill(0);
|
||||||
|
|
||||||
|
events.forEach(event => {
|
||||||
|
const date = new Date(event.timestamp);
|
||||||
|
const hour = date.getHours();
|
||||||
|
const dayOfWeek = date.getDay();
|
||||||
|
|
||||||
|
hourCounts[hour] += 1;
|
||||||
|
dayOfWeekCounts[dayOfWeek] += 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const peakHour = hourCounts.indexOf(Math.max(...hourCounts));
|
||||||
|
const peakDay = dayOfWeekCounts.indexOf(Math.max(...dayOfWeekCounts));
|
||||||
|
|
||||||
|
const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||||
|
|
||||||
|
const analysis = {
|
||||||
|
peakHour: `${peakHour}:00`,
|
||||||
|
peakDay: dayNames[peakDay],
|
||||||
|
hourlyDistribution: hourCounts,
|
||||||
|
dailyDistribution: dayOfWeekCounts,
|
||||||
|
totalEvents: events.length
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info('Peak usage times analyzed', {
|
||||||
|
event: 'analytics_peak_usage',
|
||||||
|
...analysis
|
||||||
|
});
|
||||||
|
|
||||||
|
return analysis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track device breakdown
|
||||||
|
* @param {string} userAgent - User agent string
|
||||||
|
*/
|
||||||
|
trackDeviceType(userAgent) {
|
||||||
|
const isMobile = /Mobile|Android|iPhone|iPad|iPod/i.test(userAgent);
|
||||||
|
const isTablet = /iPad|Android.*Tablet/i.test(userAgent);
|
||||||
|
const isDesktop = !isMobile && !isTablet;
|
||||||
|
|
||||||
|
const deviceType = isTablet ? 'tablet' : isMobile ? 'mobile' : 'desktop';
|
||||||
|
|
||||||
|
logger.info('Device type tracked', {
|
||||||
|
event: 'analytics_device_type',
|
||||||
|
deviceType,
|
||||||
|
userAgent: userAgent.substring(0, 100)
|
||||||
|
});
|
||||||
|
|
||||||
|
return deviceType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Track email deliverability
|
||||||
|
* @param {string} status - Status (success, bounce, spam, failed)
|
||||||
|
* @param {string} campaignId - Campaign identifier
|
||||||
|
* @param {Object} details - Additional details
|
||||||
|
*/
|
||||||
|
trackEmailDeliverability(status, campaignId, details = {}) {
|
||||||
|
const deliverabilityData = {
|
||||||
|
status,
|
||||||
|
campaignId,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
...details
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info('Email deliverability tracked', {
|
||||||
|
event: 'analytics_email_deliverability',
|
||||||
|
...deliverabilityData
|
||||||
|
});
|
||||||
|
|
||||||
|
return deliverabilityData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate campaign analytics report
|
||||||
|
* @param {string} campaignId - Campaign identifier
|
||||||
|
* @param {Object} data - Campaign data including visitors, participants, emails sent, etc.
|
||||||
|
*/
|
||||||
|
generateCampaignReport(campaignId, data) {
|
||||||
|
const {
|
||||||
|
visitors = 0,
|
||||||
|
participants = 0,
|
||||||
|
emailsSent = 0,
|
||||||
|
emailsFailed = 0,
|
||||||
|
responsesReceived = 0,
|
||||||
|
shareCount = 0,
|
||||||
|
avgTimeOnPage = 0
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
const conversionRate = visitors > 0 ? ((participants / visitors) * 100).toFixed(2) : 0;
|
||||||
|
const emailSuccessRate = emailsSent > 0 ? (((emailsSent - emailsFailed) / emailsSent) * 100).toFixed(2) : 0;
|
||||||
|
const responseRate = emailsSent > 0 ? ((responsesReceived / emailsSent) * 100).toFixed(2) : 0;
|
||||||
|
const shareRate = participants > 0 ? ((shareCount / participants) * 100).toFixed(2) : 0;
|
||||||
|
|
||||||
|
const report = {
|
||||||
|
campaignId,
|
||||||
|
metrics: {
|
||||||
|
visitors,
|
||||||
|
participants,
|
||||||
|
conversionRate: `${conversionRate}%`,
|
||||||
|
emailsSent,
|
||||||
|
emailsFailed,
|
||||||
|
emailSuccessRate: `${emailSuccessRate}%`,
|
||||||
|
responsesReceived,
|
||||||
|
responseRate: `${responseRate}%`,
|
||||||
|
shareCount,
|
||||||
|
shareRate: `${shareRate}%`,
|
||||||
|
avgTimeOnPage: `${avgTimeOnPage}s`
|
||||||
|
},
|
||||||
|
insights: {
|
||||||
|
performance: conversionRate > 5 ? 'excellent' : conversionRate > 2 ? 'good' : 'needs improvement',
|
||||||
|
emailHealth: emailSuccessRate > 95 ? 'excellent' : emailSuccessRate > 85 ? 'good' : 'poor',
|
||||||
|
engagement: responseRate > 10 ? 'high' : responseRate > 3 ? 'medium' : 'low'
|
||||||
|
},
|
||||||
|
generatedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info('Campaign report generated', {
|
||||||
|
event: 'analytics_campaign_report',
|
||||||
|
campaignId,
|
||||||
|
...report.metrics
|
||||||
|
});
|
||||||
|
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user retention summary
|
||||||
|
* @returns {Object} User retention statistics
|
||||||
|
*/
|
||||||
|
getUserRetentionSummary() {
|
||||||
|
let oneTimeUsers = 0;
|
||||||
|
let repeatUsers = 0;
|
||||||
|
let totalActions = 0;
|
||||||
|
|
||||||
|
for (const [userId, userData] of this.cache.userRetention.entries()) {
|
||||||
|
totalActions += userData.actionCount;
|
||||||
|
|
||||||
|
const daysBetween = this.getDaysBetween(userData.firstSeen, userData.lastSeen);
|
||||||
|
if (daysBetween > 0 || userData.actionCount > 1) {
|
||||||
|
repeatUsers += 1;
|
||||||
|
} else {
|
||||||
|
oneTimeUsers += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalUsers = oneTimeUsers + repeatUsers;
|
||||||
|
const retentionRate = totalUsers > 0 ? ((repeatUsers / totalUsers) * 100).toFixed(2) : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalUsers,
|
||||||
|
oneTimeUsers,
|
||||||
|
repeatUsers,
|
||||||
|
retentionRate: `${retentionRate}%`,
|
||||||
|
avgActionsPerUser: totalUsers > 0 ? (totalActions / totalUsers).toFixed(2) : 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Calculate days between two dates
|
||||||
|
*/
|
||||||
|
getDaysBetween(date1, date2) {
|
||||||
|
const d1 = new Date(date1);
|
||||||
|
const d2 = new Date(date2);
|
||||||
|
const diffTime = Math.abs(d2 - d1);
|
||||||
|
return Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear cache (for testing or memory management)
|
||||||
|
*/
|
||||||
|
clearCache() {
|
||||||
|
this.cache.campaignStats.clear();
|
||||||
|
this.cache.userRetention.clear();
|
||||||
|
this.cache.geographicData.clear();
|
||||||
|
logger.info('Analytics cache cleared');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new Analytics();
|
||||||
380
influence/app/utils/health-check.js
Normal file
@ -0,0 +1,380 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
const os = require('os');
|
||||||
|
const fs = require('fs').promises;
|
||||||
|
const path = require('path');
|
||||||
|
const nodemailer = require('nodemailer');
|
||||||
|
const logger = require('./logger');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Health check utility for monitoring all system dependencies
|
||||||
|
*/
|
||||||
|
class HealthCheck {
|
||||||
|
constructor() {
|
||||||
|
this.services = {
|
||||||
|
nocodb: { name: 'NocoDB', healthy: false, lastCheck: null, details: {} },
|
||||||
|
smtp: { name: 'SMTP Server', healthy: false, lastCheck: null, details: {} },
|
||||||
|
representAPI: { name: 'Represent API', healthy: false, lastCheck: null, details: {} },
|
||||||
|
disk: { name: 'Disk Space', healthy: false, lastCheck: null, details: {} },
|
||||||
|
memory: { name: 'Memory', healthy: false, lastCheck: null, details: {} },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check NocoDB connectivity and API health
|
||||||
|
*/
|
||||||
|
async checkNocoDB() {
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${process.env.NOCODB_API_URL}/db/meta/projects`, {
|
||||||
|
headers: {
|
||||||
|
'xc-token': process.env.NOCODB_API_TOKEN
|
||||||
|
},
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
this.services.nocodb = {
|
||||||
|
name: 'NocoDB',
|
||||||
|
healthy: response.status === 200,
|
||||||
|
lastCheck: new Date().toISOString(),
|
||||||
|
details: {
|
||||||
|
status: response.status,
|
||||||
|
responseTime: `${duration}ms`,
|
||||||
|
url: process.env.NOCODB_API_URL
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.logHealthCheck('nocodb', 'healthy', { responseTime: duration });
|
||||||
|
return this.services.nocodb;
|
||||||
|
} catch (error) {
|
||||||
|
this.services.nocodb = {
|
||||||
|
name: 'NocoDB',
|
||||||
|
healthy: false,
|
||||||
|
lastCheck: new Date().toISOString(),
|
||||||
|
details: {
|
||||||
|
error: error.message,
|
||||||
|
url: process.env.NOCODB_API_URL
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.logHealthCheck('nocodb', 'unhealthy', { error: error.message });
|
||||||
|
return this.services.nocodb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check SMTP server connectivity
|
||||||
|
*/
|
||||||
|
async checkSMTP() {
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
// Skip SMTP check if in test mode
|
||||||
|
if (process.env.EMAIL_TEST_MODE === 'true') {
|
||||||
|
this.services.smtp = {
|
||||||
|
name: 'SMTP Server',
|
||||||
|
healthy: true,
|
||||||
|
lastCheck: new Date().toISOString(),
|
||||||
|
details: {
|
||||||
|
mode: 'test',
|
||||||
|
message: 'SMTP test mode enabled - emails logged only'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return this.services.smtp;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
host: process.env.SMTP_HOST,
|
||||||
|
port: parseInt(process.env.SMTP_PORT || '587'),
|
||||||
|
secure: process.env.SMTP_SECURE === 'true',
|
||||||
|
auth: {
|
||||||
|
user: process.env.SMTP_USER,
|
||||||
|
pass: process.env.SMTP_PASS
|
||||||
|
},
|
||||||
|
connectionTimeout: 5000
|
||||||
|
});
|
||||||
|
|
||||||
|
await transporter.verify();
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
|
||||||
|
this.services.smtp = {
|
||||||
|
name: 'SMTP Server',
|
||||||
|
healthy: true,
|
||||||
|
lastCheck: new Date().toISOString(),
|
||||||
|
details: {
|
||||||
|
host: process.env.SMTP_HOST,
|
||||||
|
port: process.env.SMTP_PORT,
|
||||||
|
responseTime: `${duration}ms`
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.logHealthCheck('smtp', 'healthy', { responseTime: duration });
|
||||||
|
return this.services.smtp;
|
||||||
|
} catch (error) {
|
||||||
|
this.services.smtp = {
|
||||||
|
name: 'SMTP Server',
|
||||||
|
healthy: false,
|
||||||
|
lastCheck: new Date().toISOString(),
|
||||||
|
details: {
|
||||||
|
error: error.message,
|
||||||
|
host: process.env.SMTP_HOST
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.logHealthCheck('smtp', 'unhealthy', { error: error.message });
|
||||||
|
return this.services.smtp;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check Represent API availability
|
||||||
|
*/
|
||||||
|
async checkRepresentAPI() {
|
||||||
|
const start = Date.now();
|
||||||
|
const testUrl = `${process.env.REPRESENT_API_BASE || 'https://represent.opennorth.ca'}/representatives/house-of-commons/`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get(testUrl, {
|
||||||
|
timeout: 5000
|
||||||
|
});
|
||||||
|
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
this.services.representAPI = {
|
||||||
|
name: 'Represent API',
|
||||||
|
healthy: response.status === 200,
|
||||||
|
lastCheck: new Date().toISOString(),
|
||||||
|
details: {
|
||||||
|
status: response.status,
|
||||||
|
responseTime: `${duration}ms`,
|
||||||
|
url: testUrl
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.logHealthCheck('represent-api', 'healthy', { responseTime: duration });
|
||||||
|
return this.services.representAPI;
|
||||||
|
} catch (error) {
|
||||||
|
this.services.representAPI = {
|
||||||
|
name: 'Represent API',
|
||||||
|
healthy: false,
|
||||||
|
lastCheck: new Date().toISOString(),
|
||||||
|
details: {
|
||||||
|
error: error.message,
|
||||||
|
url: testUrl
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.logHealthCheck('represent-api', 'unhealthy', { error: error.message });
|
||||||
|
return this.services.representAPI;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check disk space for uploads directory
|
||||||
|
*/
|
||||||
|
async checkDiskSpace() {
|
||||||
|
try {
|
||||||
|
const uploadsDir = path.join(__dirname, '../public/uploads');
|
||||||
|
|
||||||
|
// Ensure directory exists
|
||||||
|
try {
|
||||||
|
await fs.access(uploadsDir);
|
||||||
|
} catch {
|
||||||
|
await fs.mkdir(uploadsDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get directory size
|
||||||
|
const directorySize = await this.getDirectorySize(uploadsDir);
|
||||||
|
|
||||||
|
// Get system disk usage (platform-specific approximation)
|
||||||
|
const freeSpace = os.freemem();
|
||||||
|
const totalSpace = os.totalmem();
|
||||||
|
const usedPercentage = ((totalSpace - freeSpace) / totalSpace) * 100;
|
||||||
|
|
||||||
|
// Consider healthy if less than 90% full
|
||||||
|
const healthy = usedPercentage < 90;
|
||||||
|
|
||||||
|
this.services.disk = {
|
||||||
|
name: 'Disk Space',
|
||||||
|
healthy,
|
||||||
|
lastCheck: new Date().toISOString(),
|
||||||
|
details: {
|
||||||
|
uploadsSize: this.formatBytes(directorySize),
|
||||||
|
systemUsedPercentage: `${usedPercentage.toFixed(2)}%`,
|
||||||
|
freeSpace: this.formatBytes(freeSpace),
|
||||||
|
totalSpace: this.formatBytes(totalSpace),
|
||||||
|
path: uploadsDir
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.logHealthCheck('disk', healthy ? 'healthy' : 'unhealthy', this.services.disk.details);
|
||||||
|
return this.services.disk;
|
||||||
|
} catch (error) {
|
||||||
|
this.services.disk = {
|
||||||
|
name: 'Disk Space',
|
||||||
|
healthy: false,
|
||||||
|
lastCheck: new Date().toISOString(),
|
||||||
|
details: {
|
||||||
|
error: error.message
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.logHealthCheck('disk', 'unhealthy', { error: error.message });
|
||||||
|
return this.services.disk;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check memory usage
|
||||||
|
*/
|
||||||
|
async checkMemory() {
|
||||||
|
try {
|
||||||
|
const totalMemory = os.totalmem();
|
||||||
|
const freeMemory = os.freemem();
|
||||||
|
const usedMemory = totalMemory - freeMemory;
|
||||||
|
const usedPercentage = (usedMemory / totalMemory) * 100;
|
||||||
|
|
||||||
|
// Get Node.js process memory
|
||||||
|
const processMemory = process.memoryUsage();
|
||||||
|
|
||||||
|
// Consider healthy if less than 85% used
|
||||||
|
const healthy = usedPercentage < 85;
|
||||||
|
|
||||||
|
this.services.memory = {
|
||||||
|
name: 'Memory',
|
||||||
|
healthy,
|
||||||
|
lastCheck: new Date().toISOString(),
|
||||||
|
details: {
|
||||||
|
system: {
|
||||||
|
total: this.formatBytes(totalMemory),
|
||||||
|
used: this.formatBytes(usedMemory),
|
||||||
|
free: this.formatBytes(freeMemory),
|
||||||
|
usedPercentage: `${usedPercentage.toFixed(2)}%`
|
||||||
|
},
|
||||||
|
process: {
|
||||||
|
rss: this.formatBytes(processMemory.rss),
|
||||||
|
heapTotal: this.formatBytes(processMemory.heapTotal),
|
||||||
|
heapUsed: this.formatBytes(processMemory.heapUsed),
|
||||||
|
external: this.formatBytes(processMemory.external)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.logHealthCheck('memory', healthy ? 'healthy' : 'unhealthy', this.services.memory.details);
|
||||||
|
return this.services.memory;
|
||||||
|
} catch (error) {
|
||||||
|
this.services.memory = {
|
||||||
|
name: 'Memory',
|
||||||
|
healthy: false,
|
||||||
|
lastCheck: new Date().toISOString(),
|
||||||
|
details: {
|
||||||
|
error: error.message
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.logHealthCheck('memory', 'unhealthy', { error: error.message });
|
||||||
|
return this.services.memory;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run all health checks
|
||||||
|
*/
|
||||||
|
async checkAll() {
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
this.checkNocoDB(),
|
||||||
|
this.checkSMTP(),
|
||||||
|
this.checkRepresentAPI(),
|
||||||
|
this.checkDiskSpace(),
|
||||||
|
this.checkMemory()
|
||||||
|
]);
|
||||||
|
|
||||||
|
const overall = {
|
||||||
|
status: Object.values(this.services).every(s => s.healthy) ? 'healthy' : 'degraded',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
services: this.services,
|
||||||
|
uptime: process.uptime(),
|
||||||
|
version: process.env.npm_package_version || '1.0.0',
|
||||||
|
environment: process.env.NODE_ENV || 'development'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if any critical services are down
|
||||||
|
// Note: In development, NocoDB might not be available yet
|
||||||
|
// Only mark as unhealthy if multiple critical services fail
|
||||||
|
const criticalServices = ['nocodb'];
|
||||||
|
const criticalDown = criticalServices.filter(name => !this.services[name].healthy);
|
||||||
|
|
||||||
|
// Allow degraded state if only external services (NocoDB, Represent API) are down
|
||||||
|
// Only fail health check if core services (disk, memory) are unhealthy
|
||||||
|
const coreServicesHealthy = this.services.disk.healthy && this.services.memory.healthy;
|
||||||
|
|
||||||
|
if (!coreServicesHealthy) {
|
||||||
|
overall.status = 'unhealthy';
|
||||||
|
} else if (criticalDown.length > 0) {
|
||||||
|
// Mark as degraded (not unhealthy) if external services are down
|
||||||
|
// This allows the container to start even if NocoDB isn't ready
|
||||||
|
overall.status = 'degraded';
|
||||||
|
}
|
||||||
|
|
||||||
|
return overall;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get health check for a specific service
|
||||||
|
*/
|
||||||
|
async checkService(serviceName) {
|
||||||
|
const checkMethods = {
|
||||||
|
nocodb: () => this.checkNocoDB(),
|
||||||
|
smtp: () => this.checkSMTP(),
|
||||||
|
representAPI: () => this.checkRepresentAPI(),
|
||||||
|
disk: () => this.checkDiskSpace(),
|
||||||
|
memory: () => this.checkMemory()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (checkMethods[serviceName]) {
|
||||||
|
return await checkMethods[serviceName]();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unknown service: ${serviceName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Get directory size recursively
|
||||||
|
*/
|
||||||
|
async getDirectorySize(directory) {
|
||||||
|
let size = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files = await fs.readdir(directory);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const filePath = path.join(directory, file);
|
||||||
|
const stats = await fs.stat(filePath);
|
||||||
|
|
||||||
|
if (stats.isDirectory()) {
|
||||||
|
size += await this.getDirectorySize(filePath);
|
||||||
|
} else {
|
||||||
|
size += stats.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Error calculating directory size: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Format bytes to human readable
|
||||||
|
*/
|
||||||
|
formatBytes(bytes) {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new HealthCheck();
|
||||||
190
influence/app/utils/logger.js
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
const winston = require('winston');
|
||||||
|
const DailyRotateFile = require('winston-daily-rotate-file');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Define log levels
|
||||||
|
const levels = {
|
||||||
|
error: 0,
|
||||||
|
warn: 1,
|
||||||
|
info: 2,
|
||||||
|
http: 3,
|
||||||
|
debug: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Define colors for each level
|
||||||
|
const colors = {
|
||||||
|
error: 'red',
|
||||||
|
warn: 'yellow',
|
||||||
|
info: 'green',
|
||||||
|
http: 'magenta',
|
||||||
|
debug: 'white',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tell winston about our colors
|
||||||
|
winston.addColors(colors);
|
||||||
|
|
||||||
|
// Define which level to log based on environment
|
||||||
|
const level = () => {
|
||||||
|
const env = process.env.NODE_ENV || 'development';
|
||||||
|
const isDevelopment = env === 'development';
|
||||||
|
return isDevelopment ? 'debug' : 'info';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Define log format
|
||||||
|
const logFormat = winston.format.combine(
|
||||||
|
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss:ms' }),
|
||||||
|
winston.format.errors({ stack: true }),
|
||||||
|
winston.format.splat(),
|
||||||
|
winston.format.json()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Console format for development (pretty print)
|
||||||
|
const consoleFormat = winston.format.combine(
|
||||||
|
winston.format.colorize({ all: true }),
|
||||||
|
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||||
|
winston.format.printf(
|
||||||
|
(info) => `${info.timestamp} ${info.level}: ${info.message}${info.stack ? '\n' + info.stack : ''}`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Define transports
|
||||||
|
const transports = [
|
||||||
|
// Console output
|
||||||
|
new winston.transports.Console({
|
||||||
|
format: consoleFormat,
|
||||||
|
handleExceptions: true,
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Error logs - rotate daily
|
||||||
|
new DailyRotateFile({
|
||||||
|
filename: path.join(__dirname, '../../logs/error-%DATE%.log'),
|
||||||
|
datePattern: 'YYYY-MM-DD',
|
||||||
|
level: 'error',
|
||||||
|
format: logFormat,
|
||||||
|
maxSize: '20m',
|
||||||
|
maxFiles: '14d',
|
||||||
|
handleExceptions: true,
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Combined logs - rotate daily
|
||||||
|
new DailyRotateFile({
|
||||||
|
filename: path.join(__dirname, '../../logs/combined-%DATE%.log'),
|
||||||
|
datePattern: 'YYYY-MM-DD',
|
||||||
|
format: logFormat,
|
||||||
|
maxSize: '20m',
|
||||||
|
maxFiles: '14d',
|
||||||
|
}),
|
||||||
|
|
||||||
|
// HTTP logs - rotate daily
|
||||||
|
new DailyRotateFile({
|
||||||
|
filename: path.join(__dirname, '../../logs/http-%DATE%.log'),
|
||||||
|
datePattern: 'YYYY-MM-DD',
|
||||||
|
level: 'http',
|
||||||
|
format: logFormat,
|
||||||
|
maxSize: '20m',
|
||||||
|
maxFiles: '7d',
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Create the logger
|
||||||
|
const logger = winston.createLogger({
|
||||||
|
level: level(),
|
||||||
|
levels,
|
||||||
|
format: logFormat,
|
||||||
|
transports,
|
||||||
|
exitOnError: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a stream object for Morgan HTTP logger
|
||||||
|
logger.stream = {
|
||||||
|
write: (message) => {
|
||||||
|
logger.http(message.trim());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper methods for common logging patterns
|
||||||
|
logger.logRequest = (req, res, duration) => {
|
||||||
|
const logData = {
|
||||||
|
method: req.method,
|
||||||
|
url: req.url,
|
||||||
|
status: res.statusCode,
|
||||||
|
duration: `${duration}ms`,
|
||||||
|
ip: req.ip,
|
||||||
|
userAgent: req.get('user-agent'),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (res.statusCode >= 500) {
|
||||||
|
logger.error('Request failed', logData);
|
||||||
|
} else if (res.statusCode >= 400) {
|
||||||
|
logger.warn('Request error', logData);
|
||||||
|
} else {
|
||||||
|
logger.http('Request completed', logData);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.logEmailSent = (recipient, campaign, status) => {
|
||||||
|
logger.info('Email sent', {
|
||||||
|
event: 'email_sent',
|
||||||
|
recipient,
|
||||||
|
campaign,
|
||||||
|
status,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.logEmailFailed = (recipient, campaign, error) => {
|
||||||
|
logger.error('Email failed', {
|
||||||
|
event: 'email_failed',
|
||||||
|
recipient,
|
||||||
|
campaign,
|
||||||
|
error: error.message,
|
||||||
|
stack: error.stack,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.logAuth = (action, username, success, ip) => {
|
||||||
|
const level = success ? 'info' : 'warn';
|
||||||
|
logger[level]('Auth action', {
|
||||||
|
event: 'auth',
|
||||||
|
action,
|
||||||
|
username,
|
||||||
|
success,
|
||||||
|
ip,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.logCampaignAction = (action, campaignId, userId, ip) => {
|
||||||
|
logger.info('Campaign action', {
|
||||||
|
event: 'campaign_action',
|
||||||
|
action,
|
||||||
|
campaignId,
|
||||||
|
userId,
|
||||||
|
ip,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.logRateLimitHit = (endpoint, ip, limit) => {
|
||||||
|
logger.warn('Rate limit hit', {
|
||||||
|
event: 'rate_limit',
|
||||||
|
endpoint,
|
||||||
|
ip,
|
||||||
|
limit,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.logHealthCheck = (service, status, details = {}) => {
|
||||||
|
const level = status === 'healthy' ? 'info' : 'error';
|
||||||
|
logger[level]('Health check', {
|
||||||
|
event: 'health_check',
|
||||||
|
service,
|
||||||
|
status,
|
||||||
|
...details,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = logger;
|
||||||
276
influence/app/utils/metrics.js
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
const client = require('prom-client');
|
||||||
|
|
||||||
|
// Create a Registry to register the metrics
|
||||||
|
const register = new client.Registry();
|
||||||
|
|
||||||
|
// Add default metrics (CPU, memory, etc.)
|
||||||
|
client.collectDefaultMetrics({
|
||||||
|
register,
|
||||||
|
prefix: 'influence_app_'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Custom metrics for the Influence application
|
||||||
|
|
||||||
|
// Email metrics
|
||||||
|
const emailsSentTotal = new client.Counter({
|
||||||
|
name: 'influence_emails_sent_total',
|
||||||
|
help: 'Total number of emails sent successfully',
|
||||||
|
labelNames: ['campaign_id', 'representative_level'],
|
||||||
|
registers: [register]
|
||||||
|
});
|
||||||
|
|
||||||
|
const emailsFailedTotal = new client.Counter({
|
||||||
|
name: 'influence_emails_failed_total',
|
||||||
|
help: 'Total number of emails that failed to send',
|
||||||
|
labelNames: ['campaign_id', 'error_type'],
|
||||||
|
registers: [register]
|
||||||
|
});
|
||||||
|
|
||||||
|
const emailQueueSize = new client.Gauge({
|
||||||
|
name: 'influence_email_queue_size',
|
||||||
|
help: 'Current number of emails in the queue',
|
||||||
|
registers: [register]
|
||||||
|
});
|
||||||
|
|
||||||
|
const emailSendDuration = new client.Histogram({
|
||||||
|
name: 'influence_email_send_duration_seconds',
|
||||||
|
help: 'Time taken to send an email',
|
||||||
|
labelNames: ['campaign_id'],
|
||||||
|
buckets: [0.1, 0.5, 1, 2, 5, 10],
|
||||||
|
registers: [register]
|
||||||
|
});
|
||||||
|
|
||||||
|
// API metrics
|
||||||
|
const httpRequestDuration = new client.Histogram({
|
||||||
|
name: 'influence_http_request_duration_seconds',
|
||||||
|
help: 'Duration of HTTP requests in seconds',
|
||||||
|
labelNames: ['method', 'route', 'status_code'],
|
||||||
|
buckets: [0.01, 0.05, 0.1, 0.5, 1, 2, 5],
|
||||||
|
registers: [register]
|
||||||
|
});
|
||||||
|
|
||||||
|
const httpRequestsTotal = new client.Counter({
|
||||||
|
name: 'influence_http_requests_total',
|
||||||
|
help: 'Total number of HTTP requests',
|
||||||
|
labelNames: ['method', 'route', 'status_code'],
|
||||||
|
registers: [register]
|
||||||
|
});
|
||||||
|
|
||||||
|
// User metrics
|
||||||
|
const activeUsersGauge = new client.Gauge({
|
||||||
|
name: 'influence_active_users_total',
|
||||||
|
help: 'Number of currently active users',
|
||||||
|
registers: [register]
|
||||||
|
});
|
||||||
|
|
||||||
|
const userRegistrationsTotal = new client.Counter({
|
||||||
|
name: 'influence_user_registrations_total',
|
||||||
|
help: 'Total number of user registrations',
|
||||||
|
registers: [register]
|
||||||
|
});
|
||||||
|
|
||||||
|
const loginAttemptsTotal = new client.Counter({
|
||||||
|
name: 'influence_login_attempts_total',
|
||||||
|
help: 'Total number of login attempts',
|
||||||
|
labelNames: ['status'],
|
||||||
|
registers: [register]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Campaign metrics
|
||||||
|
const campaignCreationsTotal = new client.Counter({
|
||||||
|
name: 'influence_campaign_creations_total',
|
||||||
|
help: 'Total number of campaigns created',
|
||||||
|
registers: [register]
|
||||||
|
});
|
||||||
|
|
||||||
|
const campaignParticipationTotal = new client.Counter({
|
||||||
|
name: 'influence_campaign_participation_total',
|
||||||
|
help: 'Total number of campaign participations',
|
||||||
|
labelNames: ['campaign_id'],
|
||||||
|
registers: [register]
|
||||||
|
});
|
||||||
|
|
||||||
|
const activeCampaignsGauge = new client.Gauge({
|
||||||
|
name: 'influence_active_campaigns_total',
|
||||||
|
help: 'Number of currently active campaigns',
|
||||||
|
registers: [register]
|
||||||
|
});
|
||||||
|
|
||||||
|
const campaignConversionRate = new client.Gauge({
|
||||||
|
name: 'influence_campaign_conversion_rate',
|
||||||
|
help: 'Campaign conversion rate (participants / visitors)',
|
||||||
|
labelNames: ['campaign_id'],
|
||||||
|
registers: [register]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Representative metrics
|
||||||
|
const representativeLookupTotal = new client.Counter({
|
||||||
|
name: 'influence_representative_lookup_total',
|
||||||
|
help: 'Total number of representative lookups',
|
||||||
|
labelNames: ['lookup_type'],
|
||||||
|
registers: [register]
|
||||||
|
});
|
||||||
|
|
||||||
|
const representativeResponsesTotal = new client.Counter({
|
||||||
|
name: 'influence_representative_responses_total',
|
||||||
|
help: 'Total number of representative responses received',
|
||||||
|
labelNames: ['representative_level'],
|
||||||
|
registers: [register]
|
||||||
|
});
|
||||||
|
|
||||||
|
const representativeResponseRate = new client.Gauge({
|
||||||
|
name: 'influence_representative_response_rate',
|
||||||
|
help: 'Representative response rate (responses / emails sent)',
|
||||||
|
labelNames: ['representative_level'],
|
||||||
|
registers: [register]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rate limiting metrics
|
||||||
|
const rateLimitHitsTotal = new client.Counter({
|
||||||
|
name: 'influence_rate_limit_hits_total',
|
||||||
|
help: 'Total number of rate limit hits',
|
||||||
|
labelNames: ['endpoint', 'limit_type'],
|
||||||
|
registers: [register]
|
||||||
|
});
|
||||||
|
|
||||||
|
// External service metrics
|
||||||
|
const externalServiceRequestsTotal = new client.Counter({
|
||||||
|
name: 'influence_external_service_requests_total',
|
||||||
|
help: 'Total number of requests to external services',
|
||||||
|
labelNames: ['service', 'status'],
|
||||||
|
registers: [register]
|
||||||
|
});
|
||||||
|
|
||||||
|
const externalServiceLatency = new client.Histogram({
|
||||||
|
name: 'influence_external_service_latency_seconds',
|
||||||
|
help: 'Latency of external service requests',
|
||||||
|
labelNames: ['service'],
|
||||||
|
buckets: [0.1, 0.5, 1, 2, 5, 10],
|
||||||
|
registers: [register]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Geographic metrics
|
||||||
|
const participationByPostalCode = new client.Counter({
|
||||||
|
name: 'influence_participation_by_postal_code_total',
|
||||||
|
help: 'Participation count by postal code prefix',
|
||||||
|
labelNames: ['postal_prefix'],
|
||||||
|
registers: [register]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Middleware to track HTTP requests
|
||||||
|
const metricsMiddleware = (req, res, next) => {
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
res.on('finish', () => {
|
||||||
|
const duration = (Date.now() - start) / 1000;
|
||||||
|
const route = req.route ? req.route.path : req.path;
|
||||||
|
const labels = {
|
||||||
|
method: req.method,
|
||||||
|
route: route,
|
||||||
|
status_code: res.statusCode
|
||||||
|
};
|
||||||
|
|
||||||
|
httpRequestDuration.observe(labels, duration);
|
||||||
|
httpRequestsTotal.inc(labels);
|
||||||
|
});
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper functions for common metric operations
|
||||||
|
const metrics = {
|
||||||
|
// Email metrics
|
||||||
|
recordEmailSent: (campaignId, representativeLevel) => {
|
||||||
|
emailsSentTotal.inc({ campaign_id: campaignId, representative_level: representativeLevel });
|
||||||
|
},
|
||||||
|
|
||||||
|
recordEmailFailed: (campaignId, errorType) => {
|
||||||
|
emailsFailedTotal.inc({ campaign_id: campaignId, error_type: errorType });
|
||||||
|
},
|
||||||
|
|
||||||
|
setEmailQueueSize: (size) => {
|
||||||
|
emailQueueSize.set(size);
|
||||||
|
},
|
||||||
|
|
||||||
|
observeEmailSendDuration: (campaignId, durationSeconds) => {
|
||||||
|
emailSendDuration.observe({ campaign_id: campaignId }, durationSeconds);
|
||||||
|
},
|
||||||
|
|
||||||
|
// User metrics
|
||||||
|
setActiveUsers: (count) => {
|
||||||
|
activeUsersGauge.set(count);
|
||||||
|
},
|
||||||
|
|
||||||
|
recordUserRegistration: () => {
|
||||||
|
userRegistrationsTotal.inc();
|
||||||
|
},
|
||||||
|
|
||||||
|
recordLoginAttempt: (success) => {
|
||||||
|
loginAttemptsTotal.inc({ status: success ? 'success' : 'failed' });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Campaign metrics
|
||||||
|
recordCampaignCreation: () => {
|
||||||
|
campaignCreationsTotal.inc();
|
||||||
|
},
|
||||||
|
|
||||||
|
recordCampaignParticipation: (campaignId) => {
|
||||||
|
campaignParticipationTotal.inc({ campaign_id: campaignId });
|
||||||
|
},
|
||||||
|
|
||||||
|
setActiveCampaigns: (count) => {
|
||||||
|
activeCampaignsGauge.set(count);
|
||||||
|
},
|
||||||
|
|
||||||
|
setCampaignConversionRate: (campaignId, rate) => {
|
||||||
|
campaignConversionRate.set({ campaign_id: campaignId }, rate);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Representative metrics
|
||||||
|
recordRepresentativeLookup: (lookupType) => {
|
||||||
|
representativeLookupTotal.inc({ lookup_type: lookupType });
|
||||||
|
},
|
||||||
|
|
||||||
|
recordRepresentativeResponse: (representativeLevel) => {
|
||||||
|
representativeResponsesTotal.inc({ representative_level: representativeLevel });
|
||||||
|
},
|
||||||
|
|
||||||
|
setRepresentativeResponseRate: (representativeLevel, rate) => {
|
||||||
|
representativeResponseRate.set({ representative_level: representativeLevel }, rate);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Rate limiting metrics
|
||||||
|
recordRateLimitHit: (endpoint, limitType) => {
|
||||||
|
rateLimitHitsTotal.inc({ endpoint, limit_type: limitType });
|
||||||
|
},
|
||||||
|
|
||||||
|
// External service metrics
|
||||||
|
recordExternalServiceRequest: (service, success) => {
|
||||||
|
externalServiceRequestsTotal.inc({ service, status: success ? 'success' : 'failed' });
|
||||||
|
},
|
||||||
|
|
||||||
|
observeExternalServiceLatency: (service, durationSeconds) => {
|
||||||
|
externalServiceLatency.observe({ service }, durationSeconds);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Geographic metrics
|
||||||
|
recordParticipationByPostalCode: (postalCode) => {
|
||||||
|
const prefix = postalCode.substring(0, 3).toUpperCase();
|
||||||
|
participationByPostalCode.inc({ postal_prefix: prefix });
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get metrics endpoint handler
|
||||||
|
getMetrics: async () => {
|
||||||
|
return await register.metrics();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Get content type for metrics
|
||||||
|
getContentType: () => {
|
||||||
|
return register.contentType;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
middleware: metricsMiddleware
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = metrics;
|
||||||
@ -16,24 +16,127 @@ function cleanupExpiredEntries() {
|
|||||||
// Clean up expired entries every minute
|
// Clean up expired entries every minute
|
||||||
setInterval(cleanupExpiredEntries, 60 * 1000);
|
setInterval(cleanupExpiredEntries, 60 * 1000);
|
||||||
|
|
||||||
// General API rate limiter
|
// Custom key generator that's safer with trust proxy
|
||||||
const general = rateLimit({
|
const safeKeyGenerator = (req) => {
|
||||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
return req.ip || req.connection?.remoteAddress || 'unknown';
|
||||||
max: 100, // limit each IP to 100 requests per windowMs
|
};
|
||||||
message: {
|
|
||||||
error: 'Too many requests from this IP, please try again later.',
|
// Production-grade rate limiting configuration
|
||||||
retryAfter: 15 * 60 // 15 minutes in seconds
|
const rateLimitConfig = {
|
||||||
|
// Email sending - very restrictive (5 emails per hour per IP)
|
||||||
|
emailSend: {
|
||||||
|
windowMs: 60 * 60 * 1000, // 1 hour
|
||||||
|
max: 5,
|
||||||
|
message: {
|
||||||
|
error: 'Email rate limit exceeded',
|
||||||
|
message: 'Too many emails sent from this IP. Maximum 5 emails per hour allowed.',
|
||||||
|
retryAfter: 3600
|
||||||
|
}
|
||||||
},
|
},
|
||||||
standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers
|
|
||||||
legacyHeaders: false, // Disable the `X-RateLimit-*` headers
|
// Representative lookup - moderate (30 lookups per minute)
|
||||||
// Use a custom key generator that's safer with trust proxy
|
representativeLookup: {
|
||||||
keyGenerator: (req) => {
|
windowMs: 60 * 1000, // 1 minute
|
||||||
// Fallback to connection remote address if req.ip is not available
|
max: 30,
|
||||||
return req.ip || req.connection?.remoteAddress || 'unknown';
|
message: {
|
||||||
|
error: 'Representative lookup rate limit exceeded',
|
||||||
|
message: 'Too many representative lookups. Maximum 30 per minute allowed.',
|
||||||
|
retryAfter: 60
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Login attempts - strict (5 attempts per 15 minutes)
|
||||||
|
login: {
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 5,
|
||||||
|
message: {
|
||||||
|
error: 'Login rate limit exceeded',
|
||||||
|
message: 'Too many login attempts. Please try again in 15 minutes.',
|
||||||
|
retryAfter: 900
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Public API - standard (100 requests per minute)
|
||||||
|
publicAPI: {
|
||||||
|
windowMs: 60 * 1000, // 1 minute
|
||||||
|
max: 100,
|
||||||
|
message: {
|
||||||
|
error: 'API rate limit exceeded',
|
||||||
|
message: 'Too many requests from this IP. Please try again later.',
|
||||||
|
retryAfter: 60
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Campaign creation/editing - moderate (10 per hour)
|
||||||
|
campaignMutation: {
|
||||||
|
windowMs: 60 * 60 * 1000, // 1 hour
|
||||||
|
max: 10,
|
||||||
|
message: {
|
||||||
|
error: 'Campaign mutation rate limit exceeded',
|
||||||
|
message: 'Too many campaign operations. Maximum 10 per hour allowed.',
|
||||||
|
retryAfter: 3600
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// General fallback - legacy compatibility
|
||||||
|
general: {
|
||||||
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: 100,
|
||||||
|
message: {
|
||||||
|
error: 'Too many requests from this IP, please try again later.',
|
||||||
|
retryAfter: 900
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create rate limiter instances
|
||||||
|
const emailSend = rateLimit({
|
||||||
|
...rateLimitConfig.emailSend,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
skipSuccessfulRequests: false,
|
||||||
|
keyGenerator: safeKeyGenerator
|
||||||
});
|
});
|
||||||
|
|
||||||
// Email sending rate limiter (general - keeps existing behavior)
|
const representativeLookup = rateLimit({
|
||||||
|
...rateLimitConfig.representativeLookup,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
keyGenerator: safeKeyGenerator
|
||||||
|
});
|
||||||
|
|
||||||
|
const login = rateLimit({
|
||||||
|
...rateLimitConfig.login,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
skipSuccessfulRequests: false, // Count all attempts
|
||||||
|
keyGenerator: safeKeyGenerator
|
||||||
|
});
|
||||||
|
|
||||||
|
const publicAPI = rateLimit({
|
||||||
|
...rateLimitConfig.publicAPI,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
keyGenerator: safeKeyGenerator
|
||||||
|
});
|
||||||
|
|
||||||
|
const campaignMutation = rateLimit({
|
||||||
|
...rateLimitConfig.campaignMutation,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
skipSuccessfulRequests: false,
|
||||||
|
keyGenerator: safeKeyGenerator
|
||||||
|
});
|
||||||
|
|
||||||
|
// General API rate limiter (legacy - kept for backward compatibility)
|
||||||
|
const general = rateLimit({
|
||||||
|
...rateLimitConfig.general,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
keyGenerator: safeKeyGenerator
|
||||||
|
});
|
||||||
|
|
||||||
|
// Email sending rate limiter (legacy - kept for backward compatibility)
|
||||||
const email = rateLimit({
|
const email = rateLimit({
|
||||||
windowMs: 60 * 60 * 1000, // 1 hour
|
windowMs: 60 * 60 * 1000, // 1 hour
|
||||||
max: 10, // limit each IP to 10 emails per hour
|
max: 10, // limit each IP to 10 emails per hour
|
||||||
@ -43,12 +146,8 @@ const email = rateLimit({
|
|||||||
},
|
},
|
||||||
standardHeaders: true,
|
standardHeaders: true,
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
skipSuccessfulRequests: false, // Don't skip counting successful requests
|
skipSuccessfulRequests: false,
|
||||||
// Use a custom key generator that's safer with trust proxy
|
keyGenerator: safeKeyGenerator
|
||||||
keyGenerator: (req) => {
|
|
||||||
// Fallback to connection remote address if req.ip is not available
|
|
||||||
return req.ip || req.connection?.remoteAddress || 'unknown';
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Custom middleware for per-recipient email rate limiting
|
// Custom middleware for per-recipient email rate limiting
|
||||||
@ -90,16 +189,23 @@ const representAPI = rateLimit({
|
|||||||
},
|
},
|
||||||
standardHeaders: true,
|
standardHeaders: true,
|
||||||
legacyHeaders: false,
|
legacyHeaders: false,
|
||||||
// Use a custom key generator that's safer with trust proxy
|
keyGenerator: safeKeyGenerator
|
||||||
keyGenerator: (req) => {
|
|
||||||
// Fallback to connection remote address if req.ip is not available
|
|
||||||
return req.ip || req.connection?.remoteAddress || 'unknown';
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
// Legacy exports (backward compatibility)
|
||||||
general,
|
general,
|
||||||
email,
|
email,
|
||||||
|
representAPI,
|
||||||
|
|
||||||
|
// New granular rate limiters
|
||||||
|
emailSend,
|
||||||
|
representativeLookup,
|
||||||
|
login,
|
||||||
|
publicAPI,
|
||||||
|
campaignMutation,
|
||||||
perRecipientEmailLimiter,
|
perRecipientEmailLimiter,
|
||||||
representAPI
|
|
||||||
|
// Export config for testing/monitoring
|
||||||
|
rateLimitConfig
|
||||||
};
|
};
|
||||||
@ -1,7 +1,31 @@
|
|||||||
// Validate Canadian postal code format
|
// Validate Canadian postal code format
|
||||||
|
// Full Canadian postal code validation with proper FSA/LDU rules
|
||||||
function validatePostalCode(postalCode) {
|
function validatePostalCode(postalCode) {
|
||||||
const regex = /^[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d$/;
|
// Remove whitespace and convert to uppercase
|
||||||
return regex.test(postalCode);
|
const cleaned = postalCode.replace(/\s/g, '').toUpperCase();
|
||||||
|
|
||||||
|
// Must be exactly 6 characters
|
||||||
|
if (cleaned.length !== 6) return false;
|
||||||
|
|
||||||
|
// Pattern: A1A 1A1 where A is letter and 1 is digit
|
||||||
|
const pattern = /^[A-Z]\d[A-Z]\d[A-Z]\d$/;
|
||||||
|
if (!pattern.test(cleaned)) return false;
|
||||||
|
|
||||||
|
// First character cannot be D, F, I, O, Q, U, W, or Z
|
||||||
|
const invalidFirstChars = ['D', 'F', 'I', 'O', 'Q', 'U', 'W', 'Z'];
|
||||||
|
if (invalidFirstChars.includes(cleaned[0])) return false;
|
||||||
|
|
||||||
|
// Second position (LDU) cannot be 0
|
||||||
|
if (cleaned[1] === '0') return false;
|
||||||
|
|
||||||
|
// Third character cannot be D, F, I, O, Q, or U
|
||||||
|
const invalidThirdChars = ['D', 'F', 'I', 'O', 'Q', 'U'];
|
||||||
|
if (invalidThirdChars.includes(cleaned[2])) return false;
|
||||||
|
|
||||||
|
// Fifth character cannot be D, F, I, O, Q, or U
|
||||||
|
if (invalidThirdChars.includes(cleaned[4])) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate Alberta postal code (starts with T)
|
// Validate Alberta postal code (starts with T)
|
||||||
@ -10,10 +34,29 @@ function validateAlbertaPostalCode(postalCode) {
|
|||||||
return formatted.startsWith('T') && validatePostalCode(postalCode);
|
return formatted.startsWith('T') && validatePostalCode(postalCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate email format
|
// Validate email format with stricter rules
|
||||||
function validateEmail(email) {
|
function validateEmail(email) {
|
||||||
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
// RFC 5322 simplified email validation
|
||||||
return regex.test(email);
|
const regex = /^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
||||||
|
|
||||||
|
if (!regex.test(email)) return false;
|
||||||
|
|
||||||
|
// Additional checks
|
||||||
|
const parts = email.split('@');
|
||||||
|
if (parts.length !== 2) return false;
|
||||||
|
|
||||||
|
const [localPart, domain] = parts;
|
||||||
|
|
||||||
|
// Local part max 64 characters
|
||||||
|
if (localPart.length > 64) return false;
|
||||||
|
|
||||||
|
// Domain must have at least one dot and valid TLD
|
||||||
|
if (!domain.includes('.')) return false;
|
||||||
|
|
||||||
|
// Check for consecutive dots
|
||||||
|
if (email.includes('..')) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format postal code to standard format (A1A 1A1)
|
// Format postal code to standard format (A1A 1A1)
|
||||||
@ -25,16 +68,57 @@ function formatPostalCode(postalCode) {
|
|||||||
return cleaned;
|
return cleaned;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sanitize string input to prevent XSS
|
// Sanitize string input to prevent XSS and injection attacks
|
||||||
function sanitizeString(str) {
|
function sanitizeString(str) {
|
||||||
if (typeof str !== 'string') return str;
|
if (typeof str !== 'string') return str;
|
||||||
|
|
||||||
return str
|
return str
|
||||||
.replace(/[<>]/g, '') // Remove angle brackets
|
.replace(/[<>]/g, '') // Remove angle brackets
|
||||||
|
.replace(/javascript:/gi, '') // Remove javascript: protocol
|
||||||
|
.replace(/on\w+\s*=/gi, '') // Remove event handlers
|
||||||
|
.replace(/eval\s*\(/gi, '') // Remove eval calls
|
||||||
.trim()
|
.trim()
|
||||||
.substring(0, 1000); // Limit length
|
.substring(0, 1000); // Limit length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sanitize HTML content for email templates
|
||||||
|
function sanitizeHtmlContent(html) {
|
||||||
|
if (typeof html !== 'string') return html;
|
||||||
|
|
||||||
|
// Remove dangerous tags and attributes
|
||||||
|
let sanitized = html
|
||||||
|
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
||||||
|
.replace(/<iframe\b[^<]*(?:(?!<\/iframe>)<[^<]*)*<\/iframe>/gi, '')
|
||||||
|
.replace(/<object\b[^<]*(?:(?!<\/object>)<[^<]*)*<\/object>/gi, '')
|
||||||
|
.replace(/<embed[^>]*>/gi, '')
|
||||||
|
.replace(/javascript:/gi, '')
|
||||||
|
.replace(/on\w+\s*=/gi, '');
|
||||||
|
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate SQL/NoSQL injection attempts in where clauses
|
||||||
|
function validateWhereClause(whereClause) {
|
||||||
|
if (typeof whereClause !== 'string') return false;
|
||||||
|
|
||||||
|
// Check for SQL injection patterns
|
||||||
|
const suspiciousPatterns = [
|
||||||
|
/;\s*drop\s+/i,
|
||||||
|
/;\s*delete\s+/i,
|
||||||
|
/;\s*update\s+/i,
|
||||||
|
/;\s*insert\s+/i,
|
||||||
|
/union\s+select/i,
|
||||||
|
/exec\s*\(/i,
|
||||||
|
/execute\s*\(/i,
|
||||||
|
/--/,
|
||||||
|
/\/\*/,
|
||||||
|
/xp_/i,
|
||||||
|
/sp_/i
|
||||||
|
];
|
||||||
|
|
||||||
|
return !suspiciousPatterns.some(pattern => pattern.test(whereClause));
|
||||||
|
}
|
||||||
|
|
||||||
// Validate required fields in request body
|
// Validate required fields in request body
|
||||||
function validateRequiredFields(body, requiredFields) {
|
function validateRequiredFields(body, requiredFields) {
|
||||||
const errors = [];
|
const errors = [];
|
||||||
@ -130,6 +214,8 @@ module.exports = {
|
|||||||
validateEmail,
|
validateEmail,
|
||||||
formatPostalCode,
|
formatPostalCode,
|
||||||
sanitizeString,
|
sanitizeString,
|
||||||
|
sanitizeHtmlContent,
|
||||||
|
validateWhereClause,
|
||||||
validateRequiredFields,
|
validateRequiredFields,
|
||||||
containsSuspiciousContent,
|
containsSuspiciousContent,
|
||||||
generateSlug,
|
generateSlug,
|
||||||
|
|||||||
@ -1,25 +1,53 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build:
|
build:
|
||||||
context: ./app
|
context: ./app
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
container_name: influence-app
|
||||||
ports:
|
ports:
|
||||||
- "3333:3333"
|
- "3333:3333"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
- REDIS_HOST=redis-changemaker
|
||||||
|
- REDIS_PORT=6379
|
||||||
volumes:
|
volumes:
|
||||||
- ./app:/usr/src/app
|
- ./app:/usr/src/app
|
||||||
- /usr/src/app/node_modules
|
- /usr/src/app/node_modules
|
||||||
restart: unless-stopped
|
- uploads-data:/usr/src/app/public/uploads
|
||||||
|
- logs-data:/usr/src/app/logs
|
||||||
|
restart: always
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3333/api/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
limits:
|
||||||
|
cpus: '2'
|
||||||
|
memory: 2G
|
||||||
|
reservations:
|
||||||
|
cpus: '0.5'
|
||||||
|
memory: 512M
|
||||||
|
networks:
|
||||||
|
- changemakerlite_changemaker-lite
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
max-size: "10m"
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
# MailHog for local email testing and development
|
volumes:
|
||||||
mailhog:
|
uploads-data:
|
||||||
image: mailhog/mailhog:latest
|
driver: local
|
||||||
ports:
|
logs-data:
|
||||||
- "1025:1025" # SMTP server
|
driver: local
|
||||||
- "8025:8025" # Web UI
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
changemaker-lite:
|
changemakerlite_changemaker-lite:
|
||||||
external: true
|
external: true
|
||||||
|
|||||||
@ -9,14 +9,22 @@ NOCODB_API_TOKEN=your_nocodb_api_token_here
|
|||||||
NOCODB_PROJECT_ID=your_project_id
|
NOCODB_PROJECT_ID=your_project_id
|
||||||
|
|
||||||
# SMTP Configuration
|
# SMTP Configuration
|
||||||
# Configure your email service provider settings
|
# Configure your email service provider settings. See below for development mode smtp
|
||||||
SMTP_HOST=smtp.your-provider.com
|
# SMTP_HOST=smtp.your-provider.com
|
||||||
SMTP_PORT=587
|
# SMTP_PORT=587
|
||||||
SMTP_SECURE=false
|
# SMTP_SECURE=false
|
||||||
SMTP_USER=your-email@domain.com
|
# SMTP_USER=your-email@domain.com
|
||||||
SMTP_PASS=your_email_password_or_app_password
|
# SMTP_PASS=your_email_password_or_app_password
|
||||||
SMTP_FROM_EMAIL=your-sender@domain.com
|
# SMTP_FROM_EMAIL=your-sender@domain.com
|
||||||
SMTP_FROM_NAME="Your Campaign Name"
|
# SMTP_FROM_NAME="Your Campaign Name"
|
||||||
|
|
||||||
|
# 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
|
# Admin Configuration
|
||||||
# Set a strong password for admin access
|
# Set a strong password for admin access
|
||||||
@ -29,7 +37,9 @@ REPRESENT_API_RATE_LIMIT=60
|
|||||||
|
|
||||||
# App Configuration
|
# App Configuration
|
||||||
# Your application URL and basic settings
|
# Your application URL and basic settings
|
||||||
|
APP_NAME="BNKops Influence"
|
||||||
APP_URL=http://localhost:3333
|
APP_URL=http://localhost:3333
|
||||||
|
BASE_URL=http://localhost:3333
|
||||||
PORT=3333
|
PORT=3333
|
||||||
SESSION_SECRET=generate_a_long_random_string_here_at_least_64_characters_long
|
SESSION_SECRET=generate_a_long_random_string_here_at_least_64_characters_long
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
@ -58,22 +68,47 @@ NOCODB_TABLE_REPRESENTATIVE_RESPONSES=
|
|||||||
NOCODB_TABLE_RESPONSE_UPVOTES=
|
NOCODB_TABLE_RESPONSE_UPVOTES=
|
||||||
NOCODB_TABLE_EMAIL_VERIFICATIONS=
|
NOCODB_TABLE_EMAIL_VERIFICATIONS=
|
||||||
|
|
||||||
|
# Redis Configuration (for email queue and caching)
|
||||||
|
# Uses centralized Redis from root docker-compose.yml
|
||||||
|
REDIS_HOST=redis-changemaker
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=
|
||||||
|
REDIS_DB=0
|
||||||
|
|
||||||
|
# Backup Configuration
|
||||||
|
BACKUP_RETENTION_DAYS=30
|
||||||
|
BACKUP_ENCRYPTION_KEY=generate_a_strong_encryption_key_here
|
||||||
|
BACKUP_BASE_DIR=/path/to/backups
|
||||||
|
USE_S3_BACKUP=false
|
||||||
|
S3_BACKUP_BUCKET=
|
||||||
|
S3_BACKUP_PREFIX=influence-backups
|
||||||
|
REMOVE_LOCAL_AFTER_S3=false
|
||||||
|
|
||||||
|
# Monitoring Configuration (optional)
|
||||||
|
GRAFANA_ADMIN_PASSWORD=change_this_for_production
|
||||||
|
|
||||||
# Optional: Development Mode Settings
|
# Optional: Development Mode Settings
|
||||||
# Uncomment and modify these for local development with MailHog
|
# Uncomment and modify these for local development with centralized MailHog
|
||||||
# SMTP_HOST=mailhog
|
# MailHog runs from root docker-compose.yml as a shared service
|
||||||
# SMTP_PORT=1025
|
SMTP_HOST=mailhog-changemaker
|
||||||
# SMTP_SECURE=false
|
SMTP_PORT=1025
|
||||||
# SMTP_USER=
|
SMTP_SECURE=false
|
||||||
# SMTP_PASS=
|
SMTP_USER=
|
||||||
# SMTP_FROM_EMAIL=dev@albertainfluence.local
|
SMTP_PASS=
|
||||||
# SMTP_FROM_NAME="BNKops Influence Campaign (DEV)"
|
SMTP_FROM_EMAIL=dev@albertainfluence.local
|
||||||
|
SMTP_FROM_NAME="BNKops Influence Campaign (DEV)"
|
||||||
|
|
||||||
# Security Notes:
|
# Security Notes:
|
||||||
# - Keep your .env file secure and never commit it to version control
|
# - Keep your .env file secure and never commit it to version control
|
||||||
# - Use strong, unique passwords for ADMIN_PASSWORD
|
# - Use strong, unique passwords for ADMIN_PASSWORD
|
||||||
# - Generate a secure random string for SESSION_SECRET
|
# - Generate a secure random string for SESSION_SECRET (64+ characters)
|
||||||
# - For production, ensure EMAIL_TEST_MODE=false
|
# - For production, ensure EMAIL_TEST_MODE=false and HTTPS=true
|
||||||
# - Use app passwords or API keys for SMTP_PASS, not your main email password
|
# - Use app passwords or API keys for SMTP_PASS, not your main email password
|
||||||
|
# - Rotate all secrets regularly (every 90 days recommended)
|
||||||
|
|
||||||
|
# Generate Secure Secrets:
|
||||||
|
# SESSION_SECRET: node -e "console.log(require('crypto').randomBytes(64).toString('hex'))"
|
||||||
|
# BACKUP_ENCRYPTION_KEY: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||||
|
|
||||||
# Common SMTP Provider Examples:
|
# Common SMTP Provider Examples:
|
||||||
#
|
#
|
||||||
@ -103,4 +138,11 @@ NOCODB_TABLE_EMAIL_VERIFICATIONS=
|
|||||||
# SMTP_PORT=587
|
# SMTP_PORT=587
|
||||||
# SMTP_SECURE=false
|
# SMTP_SECURE=false
|
||||||
# SMTP_USER=apikey
|
# SMTP_USER=apikey
|
||||||
# SMTP_PASS=your_sendgrid_api_key
|
# SMTP_PASS=your_sendgrid_api_key
|
||||||
|
#
|
||||||
|
# AWS SES:
|
||||||
|
# SMTP_HOST=email-smtp.us-east-1.amazonaws.com
|
||||||
|
# SMTP_PORT=587
|
||||||
|
# SMTP_SECURE=false
|
||||||
|
# SMTP_USER=your_aws_smtp_username
|
||||||
|
# SMTP_PASS=your_aws_smtp_password
|
||||||
@ -56,6 +56,27 @@ The application includes a flexible campaign configuration system that allows ad
|
|||||||
- Paused: Temporarily disabled
|
- Paused: Temporarily disabled
|
||||||
- Archived: Completed campaigns, read-only
|
- 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
|
### Technical Implementation
|
||||||
|
|
||||||
**Database Schema** (`build-nocodb.sh`):
|
**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
|
- `getCampaignBySlug()` - Public campaign access for landing pages and participation
|
||||||
- `participateInCampaign()` - Handles user participation, representative lookup, and email sending
|
- `participateInCampaign()` - Handles user participation, representative lookup, and email sending
|
||||||
- `getCampaignAnalytics()` - Generates campaign performance metrics and participation statistics
|
- `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
|
- 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
|
- 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
|
- 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
|
- 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/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` - 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
|
- 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/track-user` - Track user participation in campaigns
|
||||||
- POST `/api/campaigns/:slug/send-email` - Send campaign emails to representatives
|
- 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
|
- Email logging, delivery tracking, and bounce handling
|
||||||
- Development mode with MailHog integration for local testing
|
- 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/`)
|
### Utilities (`app/utils/`)
|
||||||
Helper functions and shared utilities:
|
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
|
- Representative lookup integration with postal code processing
|
||||||
- Email composition interface with campaign context and template integration
|
- Email composition interface with campaign context and template integration
|
||||||
- Progress tracking through campaign participation workflow
|
- 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
|
- **`campaigns-grid.js`** - Public campaigns grid display for homepage
|
||||||
- `CampaignsGrid` class for displaying active campaigns in a responsive card layout
|
- `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
|
- Configuration status display and troubleshooting tools
|
||||||
- Real-time UI updates and comprehensive error handling
|
- 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.js`** - Login page functionality and user experience
|
||||||
- Login form handling with client-side validation
|
- Login form handling with client-side validation
|
||||||
- Integration with authentication API and session management
|
- Integration with authentication API and session management
|
||||||
|
|||||||
@ -54,7 +54,16 @@ The application supports flexible campaign configuration through the admin panel
|
|||||||
- Federal, Provincial, Municipal, School Board
|
- Federal, Provincial, Municipal, School Board
|
||||||
- Filters which representatives are shown
|
- 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
|
- Draft: Testing only, hidden from public
|
||||||
- Active: Visible on main page
|
- Active: Visible on main page
|
||||||
- Paused: Temporarily disabled
|
- Paused: Temporarily disabled
|
||||||
|
|||||||
306
influence/scripts/backup.sh
Executable file
@ -0,0 +1,306 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
# Influence App Backup Script
|
||||||
|
# Automated backup for NocoDB data, uploaded files, and configurations
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
set -e # Exit on error
|
||||||
|
|
||||||
|
# Configuration
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
APP_DIR="$(dirname "$SCRIPT_DIR")"
|
||||||
|
BACKUP_DIR="${BACKUP_BASE_DIR:-$APP_DIR/backups}"
|
||||||
|
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||||
|
BACKUP_NAME="influence_backup_${TIMESTAMP}"
|
||||||
|
BACKUP_PATH="$BACKUP_DIR/$BACKUP_NAME"
|
||||||
|
|
||||||
|
# Retention settings
|
||||||
|
RETENTION_DAYS=${BACKUP_RETENTION_DAYS:-30}
|
||||||
|
|
||||||
|
# S3/External storage settings (optional)
|
||||||
|
USE_S3=${USE_S3_BACKUP:-false}
|
||||||
|
S3_BUCKET=${S3_BACKUP_BUCKET:-""}
|
||||||
|
S3_PREFIX=${S3_BACKUP_PREFIX:-"influence-backups"}
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
# Logging functions
|
||||||
|
log_info() {
|
||||||
|
echo -e "${GREEN}[INFO]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_warn() {
|
||||||
|
echo -e "${YELLOW}[WARN]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
log_error() {
|
||||||
|
echo -e "${RED}[ERROR]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Check if required commands exist
|
||||||
|
check_dependencies() {
|
||||||
|
local missing_deps=()
|
||||||
|
|
||||||
|
for cmd in tar gzip du; do
|
||||||
|
if ! command -v $cmd &> /dev/null; then
|
||||||
|
missing_deps+=($cmd)
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "$USE_S3" = "true" ]; then
|
||||||
|
if ! command -v aws &> /dev/null; then
|
||||||
|
missing_deps+=(aws-cli)
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ${#missing_deps[@]} -ne 0 ]; then
|
||||||
|
log_error "Missing dependencies: ${missing_deps[*]}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create backup directory
|
||||||
|
create_backup_dir() {
|
||||||
|
log_info "Creating backup directory: $BACKUP_PATH"
|
||||||
|
mkdir -p "$BACKUP_PATH"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Backup uploaded files
|
||||||
|
backup_uploads() {
|
||||||
|
log_info "Backing up uploaded files..."
|
||||||
|
|
||||||
|
local uploads_dir="$APP_DIR/public/uploads"
|
||||||
|
|
||||||
|
if [ -d "$uploads_dir" ]; then
|
||||||
|
local size=$(du -sh "$uploads_dir" | cut -f1)
|
||||||
|
log_info "Uploads directory size: $size"
|
||||||
|
|
||||||
|
tar -czf "$BACKUP_PATH/uploads.tar.gz" -C "$APP_DIR/public" uploads
|
||||||
|
log_info "Uploads backed up successfully"
|
||||||
|
else
|
||||||
|
log_warn "Uploads directory not found, skipping"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Backup environment configuration (encrypted)
|
||||||
|
backup_env_config() {
|
||||||
|
log_info "Backing up environment configuration..."
|
||||||
|
|
||||||
|
local env_file="$APP_DIR/.env"
|
||||||
|
|
||||||
|
if [ -f "$env_file" ]; then
|
||||||
|
# Copy .env file (will be encrypted later)
|
||||||
|
cp "$env_file" "$BACKUP_PATH/.env"
|
||||||
|
|
||||||
|
# Encrypt if encryption key is provided
|
||||||
|
if [ -n "${BACKUP_ENCRYPTION_KEY}" ]; then
|
||||||
|
log_info "Encrypting environment file..."
|
||||||
|
openssl enc -aes-256-cbc -salt -pbkdf2 \
|
||||||
|
-in "$BACKUP_PATH/.env" \
|
||||||
|
-out "$BACKUP_PATH/.env.encrypted" \
|
||||||
|
-k "${BACKUP_ENCRYPTION_KEY}"
|
||||||
|
rm "$BACKUP_PATH/.env" # Remove unencrypted version
|
||||||
|
log_info "Environment file encrypted"
|
||||||
|
else
|
||||||
|
log_warn "BACKUP_ENCRYPTION_KEY not set, .env file not encrypted"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log_warn ".env file not found, skipping"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Backup NocoDB data (if accessible)
|
||||||
|
backup_nocodb() {
|
||||||
|
log_info "Checking NocoDB backup capability..."
|
||||||
|
|
||||||
|
# Load environment variables
|
||||||
|
if [ -f "$APP_DIR/.env" ]; then
|
||||||
|
source "$APP_DIR/.env"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If NocoDB is accessible via API, export data
|
||||||
|
if [ -n "$NOCODB_API_URL" ] && [ -n "$NOCODB_API_TOKEN" ]; then
|
||||||
|
log_info "Exporting NocoDB metadata..."
|
||||||
|
|
||||||
|
# Export project metadata
|
||||||
|
curl -s -H "xc-token: $NOCODB_API_TOKEN" \
|
||||||
|
"$NOCODB_API_URL/api/v1/db/meta/projects" \
|
||||||
|
-o "$BACKUP_PATH/nocodb_projects.json" || log_warn "Failed to export NocoDB projects"
|
||||||
|
|
||||||
|
log_info "NocoDB metadata exported"
|
||||||
|
else
|
||||||
|
log_warn "NocoDB credentials not available, skipping data export"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Backup logs
|
||||||
|
backup_logs() {
|
||||||
|
log_info "Backing up log files..."
|
||||||
|
|
||||||
|
local logs_dir="$APP_DIR/logs"
|
||||||
|
|
||||||
|
if [ -d "$logs_dir" ]; then
|
||||||
|
# Only backup logs from last 7 days
|
||||||
|
find "$logs_dir" -name "*.log" -mtime -7 -print0 | \
|
||||||
|
tar -czf "$BACKUP_PATH/logs.tar.gz" --null -T -
|
||||||
|
log_info "Logs backed up successfully"
|
||||||
|
else
|
||||||
|
log_warn "Logs directory not found, skipping"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create backup manifest
|
||||||
|
create_manifest() {
|
||||||
|
log_info "Creating backup manifest..."
|
||||||
|
|
||||||
|
cat > "$BACKUP_PATH/manifest.txt" << EOF
|
||||||
|
Influence App Backup
|
||||||
|
====================
|
||||||
|
Backup Date: $(date)
|
||||||
|
Backup Name: $BACKUP_NAME
|
||||||
|
Server: $(hostname)
|
||||||
|
|
||||||
|
Contents:
|
||||||
|
$(ls -lh "$BACKUP_PATH")
|
||||||
|
|
||||||
|
Total Size: $(du -sh "$BACKUP_PATH" | cut -f1)
|
||||||
|
EOF
|
||||||
|
|
||||||
|
log_info "Manifest created"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Compress entire backup
|
||||||
|
compress_backup() {
|
||||||
|
log_info "Compressing backup..."
|
||||||
|
|
||||||
|
cd "$BACKUP_DIR"
|
||||||
|
tar -czf "${BACKUP_NAME}.tar.gz" "$BACKUP_NAME"
|
||||||
|
|
||||||
|
# Remove uncompressed directory
|
||||||
|
rm -rf "$BACKUP_NAME"
|
||||||
|
|
||||||
|
local size=$(du -sh "${BACKUP_NAME}.tar.gz" | cut -f1)
|
||||||
|
log_info "Backup compressed: ${BACKUP_NAME}.tar.gz ($size)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Upload to S3 (if enabled)
|
||||||
|
upload_to_s3() {
|
||||||
|
if [ "$USE_S3" != "true" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$S3_BUCKET" ]; then
|
||||||
|
log_error "S3_BUCKET not set, cannot upload to S3"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Uploading backup to S3..."
|
||||||
|
|
||||||
|
aws s3 cp "${BACKUP_DIR}/${BACKUP_NAME}.tar.gz" \
|
||||||
|
"s3://${S3_BUCKET}/${S3_PREFIX}/${BACKUP_NAME}.tar.gz" \
|
||||||
|
--storage-class STANDARD_IA
|
||||||
|
|
||||||
|
if [ $? -eq 0 ]; then
|
||||||
|
log_info "Backup uploaded to S3 successfully"
|
||||||
|
|
||||||
|
# Optionally remove local backup after successful S3 upload
|
||||||
|
if [ "${REMOVE_LOCAL_AFTER_S3:-false}" = "true" ]; then
|
||||||
|
log_info "Removing local backup after S3 upload"
|
||||||
|
rm "${BACKUP_DIR}/${BACKUP_NAME}.tar.gz"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log_error "Failed to upload backup to S3"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Clean old backups
|
||||||
|
cleanup_old_backups() {
|
||||||
|
log_info "Cleaning up backups older than $RETENTION_DAYS days..."
|
||||||
|
|
||||||
|
# Clean local backups
|
||||||
|
find "$BACKUP_DIR" -name "influence_backup_*.tar.gz" -mtime +$RETENTION_DAYS -delete
|
||||||
|
|
||||||
|
# Clean S3 backups (if enabled)
|
||||||
|
if [ "$USE_S3" = "true" ] && [ -n "$S3_BUCKET" ]; then
|
||||||
|
log_info "Cleaning old S3 backups..."
|
||||||
|
|
||||||
|
# This requires AWS CLI with proper permissions
|
||||||
|
aws s3 ls "s3://${S3_BUCKET}/${S3_PREFIX}/" | \
|
||||||
|
while read -r line; do
|
||||||
|
createDate=$(echo $line | awk '{print $1" "$2}')
|
||||||
|
createDateSec=$(date -d "$createDate" +%s)
|
||||||
|
olderThan=$(date -d "-${RETENTION_DAYS} days" +%s)
|
||||||
|
|
||||||
|
if [ $createDateSec -lt $olderThan ]; then
|
||||||
|
fileName=$(echo $line | awk '{print $4}')
|
||||||
|
if [ -n "$fileName" ]; then
|
||||||
|
aws s3 rm "s3://${S3_BUCKET}/${S3_PREFIX}/${fileName}"
|
||||||
|
log_info "Deleted old S3 backup: $fileName"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Cleanup completed"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Verify backup integrity
|
||||||
|
verify_backup() {
|
||||||
|
log_info "Verifying backup integrity..."
|
||||||
|
|
||||||
|
if tar -tzf "${BACKUP_DIR}/${BACKUP_NAME}.tar.gz" > /dev/null 2>&1; then
|
||||||
|
log_info "Backup integrity verified successfully"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
log_error "Backup integrity check failed!"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Main backup process
|
||||||
|
main() {
|
||||||
|
log_info "Starting Influence App backup..."
|
||||||
|
log_info "Backup path: $BACKUP_PATH"
|
||||||
|
|
||||||
|
# Check dependencies
|
||||||
|
check_dependencies
|
||||||
|
|
||||||
|
# Create backup directory
|
||||||
|
create_backup_dir
|
||||||
|
|
||||||
|
# Perform backups
|
||||||
|
backup_uploads
|
||||||
|
backup_env_config
|
||||||
|
backup_nocodb
|
||||||
|
backup_logs
|
||||||
|
|
||||||
|
# Create manifest
|
||||||
|
create_manifest
|
||||||
|
|
||||||
|
# Compress backup
|
||||||
|
compress_backup
|
||||||
|
|
||||||
|
# Verify backup
|
||||||
|
if ! verify_backup; then
|
||||||
|
log_error "Backup verification failed, aborting"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Upload to S3
|
||||||
|
upload_to_s3 || log_warn "S3 upload failed or skipped"
|
||||||
|
|
||||||
|
# Cleanup old backups
|
||||||
|
cleanup_old_backups
|
||||||
|
|
||||||
|
log_info "Backup completed successfully: ${BACKUP_NAME}.tar.gz"
|
||||||
|
log_info "Backup size: $(du -sh "${BACKUP_DIR}/${BACKUP_NAME}.tar.gz" | cut -f1)"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run main function
|
||||||
|
main "$@"
|
||||||
@ -1081,12 +1081,24 @@ create_campaigns_table() {
|
|||||||
"uidt": "Checkbox",
|
"uidt": "Checkbox",
|
||||||
"cdf": "false"
|
"cdf": "false"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"column_name": "allow_custom_recipients",
|
||||||
|
"title": "Allow Custom Recipients",
|
||||||
|
"uidt": "Checkbox",
|
||||||
|
"cdf": "false"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"column_name": "show_response_wall",
|
"column_name": "show_response_wall",
|
||||||
"title": "Show Response Wall Button",
|
"title": "Show Response Wall Button",
|
||||||
"uidt": "Checkbox",
|
"uidt": "Checkbox",
|
||||||
"cdf": "false"
|
"cdf": "false"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"column_name": "highlight_campaign",
|
||||||
|
"title": "Highlight Campaign",
|
||||||
|
"uidt": "Checkbox",
|
||||||
|
"cdf": "false"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"column_name": "target_government_levels",
|
"column_name": "target_government_levels",
|
||||||
"title": "Target Government Levels",
|
"title": "Target Government Levels",
|
||||||
@ -1467,6 +1479,36 @@ create_representative_responses_table() {
|
|||||||
"uidt": "Checkbox",
|
"uidt": "Checkbox",
|
||||||
"cdf": "false"
|
"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",
|
"column_name": "upvote_count",
|
||||||
"title": "Upvote Count",
|
"title": "Upvote Count",
|
||||||
@ -1615,6 +1657,76 @@ create_users_table() {
|
|||||||
create_table "$base_id" "influence_users" "$table_data" "User authentication and management"
|
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
|
# Function to create the email verifications table
|
||||||
create_email_verifications_table() {
|
create_email_verifications_table() {
|
||||||
local base_id=$1
|
local base_id=$1
|
||||||
@ -1714,6 +1826,7 @@ update_env_with_table_ids() {
|
|||||||
local representative_responses_table_id=$9
|
local representative_responses_table_id=$9
|
||||||
local response_upvotes_table_id=${10}
|
local response_upvotes_table_id=${10}
|
||||||
local email_verifications_table_id=${11}
|
local email_verifications_table_id=${11}
|
||||||
|
local custom_recipients_table_id=${12}
|
||||||
|
|
||||||
print_status "Updating .env file with NocoDB project and table IDs..."
|
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_REPRESENTATIVE_RESPONSES" "$representative_responses_table_id"
|
||||||
update_env_var "NOCODB_TABLE_RESPONSE_UPVOTES" "$response_upvotes_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_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"
|
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_CALLS=$call_logs_table_id"
|
||||||
print_status "NOCODB_TABLE_REPRESENTATIVE_RESPONSES=$representative_responses_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_RESPONSE_UPVOTES=$response_upvotes_table_id"
|
||||||
|
print_status "NOCODB_TABLE_CUSTOM_RECIPIENTS=$custom_recipients_table_id"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -1896,8 +2011,15 @@ main() {
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
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
|
# 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"
|
print_error "One or more table IDs are invalid"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
@ -1922,6 +2044,7 @@ main() {
|
|||||||
table_mapping["influence_representative_responses"]="$REPRESENTATIVE_RESPONSES_TABLE_ID"
|
table_mapping["influence_representative_responses"]="$REPRESENTATIVE_RESPONSES_TABLE_ID"
|
||||||
table_mapping["influence_response_upvotes"]="$RESPONSE_UPVOTES_TABLE_ID"
|
table_mapping["influence_response_upvotes"]="$RESPONSE_UPVOTES_TABLE_ID"
|
||||||
table_mapping["influence_email_verifications"]="$EMAIL_VERIFICATIONS_TABLE_ID"
|
table_mapping["influence_email_verifications"]="$EMAIL_VERIFICATIONS_TABLE_ID"
|
||||||
|
table_mapping["influence_custom_recipients"]="$CUSTOM_RECIPIENTS_TABLE_ID"
|
||||||
|
|
||||||
# Get source table information
|
# Get source table information
|
||||||
local source_tables_response
|
local source_tables_response
|
||||||
@ -1999,7 +2122,7 @@ main() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# Update .env file with table IDs
|
# 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 ""
|
||||||
print_status "============================================================"
|
print_status "============================================================"
|
||||||
|
|||||||
204
influence/scripts/toggle-smtp.sh
Executable file
@ -0,0 +1,204 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Toggle SMTP Configuration Script
|
||||||
|
# Switches between development (MailHog) and production (ProtonMail) SMTP settings
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||||
|
ENV_FILE="$SCRIPT_DIR/../.env"
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
BLUE='\033[0;34m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
echo -e "${BLUE}"
|
||||||
|
cat << "EOF"
|
||||||
|
╔══════════════════════════════════════════════════╗
|
||||||
|
║ SMTP Configuration Toggle Tool ║
|
||||||
|
║ Influence Campaign Application ║
|
||||||
|
╚══════════════════════════════════════════════════╝
|
||||||
|
EOF
|
||||||
|
echo -e "${NC}"
|
||||||
|
|
||||||
|
# Check if .env file exists
|
||||||
|
if [ ! -f "$ENV_FILE" ]; then
|
||||||
|
echo -e "${RED}Error: .env file not found at $ENV_FILE${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Detect current mode
|
||||||
|
if grep -q "^SMTP_HOST=mailhog" "$ENV_FILE"; then
|
||||||
|
CURRENT_MODE="development"
|
||||||
|
CURRENT_HOST="mailhog"
|
||||||
|
elif grep -q "^SMTP_HOST=smtp.protonmail.ch" "$ENV_FILE"; then
|
||||||
|
CURRENT_MODE="production"
|
||||||
|
CURRENT_HOST="smtp.protonmail.ch"
|
||||||
|
else
|
||||||
|
CURRENT_MODE="unknown"
|
||||||
|
CURRENT_HOST=$(grep "^SMTP_HOST=" "$ENV_FILE" | cut -d'=' -f2)
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo -e "${YELLOW}Current Configuration:${NC}"
|
||||||
|
echo -e " Mode: ${BLUE}$CURRENT_MODE${NC}"
|
||||||
|
echo -e " SMTP Host: ${BLUE}$CURRENT_HOST${NC}"
|
||||||
|
echo -e " Email Test Mode: ${BLUE}$(grep "^EMAIL_TEST_MODE=" "$ENV_FILE" | cut -d'=' -f2)${NC}"
|
||||||
|
echo -e " Node Environment: ${BLUE}$(grep "^NODE_ENV=" "$ENV_FILE" | cut -d'=' -f2)${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Ask user what mode they want
|
||||||
|
echo "Select SMTP configuration mode:"
|
||||||
|
echo " 1) Development (MailHog - emails captured locally)"
|
||||||
|
echo " 2) Production (ProtonMail - emails sent to real recipients)"
|
||||||
|
echo " 3) Cancel"
|
||||||
|
echo ""
|
||||||
|
read -p "Enter your choice (1-3): " choice
|
||||||
|
|
||||||
|
case $choice in
|
||||||
|
1)
|
||||||
|
TARGET_MODE="development"
|
||||||
|
;;
|
||||||
|
2)
|
||||||
|
TARGET_MODE="production"
|
||||||
|
;;
|
||||||
|
3)
|
||||||
|
echo -e "${YELLOW}Cancelled. No changes made.${NC}"
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo -e "${RED}Invalid choice. Exiting.${NC}"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Confirm if already in target mode
|
||||||
|
if [ "$CURRENT_MODE" = "$TARGET_MODE" ]; then
|
||||||
|
echo -e "${GREEN}Already in $TARGET_MODE mode. No changes needed.${NC}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Create backup
|
||||||
|
BACKUP_FILE="${ENV_FILE}.backup_$(date +%Y%m%d_%H%M%S)"
|
||||||
|
cp "$ENV_FILE" "$BACKUP_FILE"
|
||||||
|
echo -e "${GREEN}✓ Backup created: $BACKUP_FILE${NC}"
|
||||||
|
|
||||||
|
# Apply configuration based on target mode
|
||||||
|
if [ "$TARGET_MODE" = "development" ]; then
|
||||||
|
echo ""
|
||||||
|
echo -e "${BLUE}Switching to DEVELOPMENT mode...${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Comment out production SMTP settings
|
||||||
|
sed -i 's/^SMTP_HOST=smtp.protonmail.ch/# SMTP_HOST=smtp.protonmail.ch/' "$ENV_FILE"
|
||||||
|
sed -i 's/^SMTP_PORT=587$/# SMTP_PORT=587/' "$ENV_FILE"
|
||||||
|
sed -i 's/^SMTP_SECURE=false$/# SMTP_SECURE=false/' "$ENV_FILE"
|
||||||
|
sed -i 's/^SMTP_USER=cmlite@bnkops.ca/# SMTP_USER=cmlite@bnkops.ca/' "$ENV_FILE"
|
||||||
|
sed -i 's/^SMTP_PASS=QMLDV1E2MWDHNJMY/# SMTP_PASS=QMLDV1E2MWDHNJMY/' "$ENV_FILE"
|
||||||
|
sed -i 's/^SMTP_FROM_EMAIL=cmlite@bnkops.ca/# SMTP_FROM_EMAIL=cmlite@bnkops.ca/' "$ENV_FILE"
|
||||||
|
sed -i 's/^SMTP_FROM_NAME="BNKops Influence Campaign"$/# SMTP_FROM_NAME="BNKops Influence Campaign"/' "$ENV_FILE"
|
||||||
|
|
||||||
|
# Uncomment development SMTP settings
|
||||||
|
sed -i 's/^# SMTP_HOST=mailhog-changemaker$/SMTP_HOST=mailhog-changemaker/' "$ENV_FILE"
|
||||||
|
sed -i 's/^# SMTP_PORT=1025$/SMTP_PORT=1025/' "$ENV_FILE"
|
||||||
|
sed -i 's/^# SMTP_SECURE=false$/SMTP_SECURE=false/' "$ENV_FILE"
|
||||||
|
sed -i 's/^# SMTP_USER=test$/SMTP_USER=test/' "$ENV_FILE"
|
||||||
|
sed -i 's/^# SMTP_PASS=test$/SMTP_PASS=test/' "$ENV_FILE"
|
||||||
|
sed -i 's/^# SMTP_FROM_EMAIL=dev@albertainfluence.local$/SMTP_FROM_EMAIL=dev@albertainfluence.local/' "$ENV_FILE"
|
||||||
|
sed -i 's/^# SMTP_FROM_NAME="BNKops Influence Campaign (DEV)"$/SMTP_FROM_NAME="BNKops Influence Campaign (DEV)"/' "$ENV_FILE"
|
||||||
|
|
||||||
|
# Update other settings
|
||||||
|
sed -i 's/^NODE_ENV=production$/NODE_ENV=development/' "$ENV_FILE"
|
||||||
|
sed -i 's/^EMAIL_TEST_MODE=false$/EMAIL_TEST_MODE=true/' "$ENV_FILE"
|
||||||
|
sed -i 's|^APP_URL=https://influence.cmlite.org$|APP_URL=http://localhost:3333|' "$ENV_FILE"
|
||||||
|
|
||||||
|
echo -e "${GREEN}✓ Configured for DEVELOPMENT mode${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}Development Configuration:${NC}"
|
||||||
|
echo " • SMTP: MailHog (mailhog-changemaker:1025)"
|
||||||
|
echo " • Web UI: http://localhost:8025"
|
||||||
|
echo " • Email Test Mode: Enabled"
|
||||||
|
echo " • Node Environment: development"
|
||||||
|
echo " • App URL: http://localhost:3333"
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}Next Steps:${NC}"
|
||||||
|
echo " 1. Ensure MailHog is running (from root directory):"
|
||||||
|
echo -e " ${BLUE}cd .. && docker compose up -d mailhog${NC}"
|
||||||
|
echo ""
|
||||||
|
echo " 2. Start the Influence app:"
|
||||||
|
echo -e " ${BLUE}docker compose up -d${NC}"
|
||||||
|
echo ""
|
||||||
|
echo " 3. Access MailHog UI to view captured emails:"
|
||||||
|
echo -e " ${BLUE}http://localhost:8025${NC}"
|
||||||
|
|
||||||
|
elif [ "$TARGET_MODE" = "production" ]; then
|
||||||
|
echo ""
|
||||||
|
echo -e "${BLUE}Switching to PRODUCTION mode...${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Confirm production switch
|
||||||
|
echo -e "${RED}⚠️ WARNING: Production mode will send REAL emails!${NC}"
|
||||||
|
read -p "Are you sure you want to switch to production? (yes/no): " confirm
|
||||||
|
|
||||||
|
if [ "$confirm" != "yes" ]; then
|
||||||
|
echo -e "${YELLOW}Cancelled. Restoring backup...${NC}"
|
||||||
|
mv "$BACKUP_FILE" "$ENV_FILE"
|
||||||
|
echo -e "${GREEN}✓ Original configuration restored${NC}"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Comment out development SMTP settings
|
||||||
|
sed -i 's/^SMTP_HOST=mailhog-changemaker$/# SMTP_HOST=mailhog-changemaker/' "$ENV_FILE"
|
||||||
|
sed -i 's/^SMTP_PORT=1025$/# SMTP_PORT=1025/' "$ENV_FILE"
|
||||||
|
sed -i 's/^SMTP_SECURE=false$/# SMTP_SECURE=false/' "$ENV_FILE"
|
||||||
|
sed -i 's/^SMTP_USER=test$/# SMTP_USER=test/' "$ENV_FILE"
|
||||||
|
sed -i 's/^SMTP_PASS=test$/# SMTP_PASS=test/' "$ENV_FILE"
|
||||||
|
sed -i 's/^SMTP_FROM_EMAIL=dev@albertainfluence.local$/# SMTP_FROM_EMAIL=dev@albertainfluence.local/' "$ENV_FILE"
|
||||||
|
sed -i 's/^SMTP_FROM_NAME="BNKops Influence Campaign (DEV)"$/# SMTP_FROM_NAME="BNKops Influence Campaign (DEV)"/' "$ENV_FILE"
|
||||||
|
|
||||||
|
# Uncomment production SMTP settings
|
||||||
|
sed -i 's/^# SMTP_HOST=smtp.protonmail.ch$/SMTP_HOST=smtp.protonmail.ch/' "$ENV_FILE"
|
||||||
|
sed -i 's/^# SMTP_PORT=587$/SMTP_PORT=587/' "$ENV_FILE"
|
||||||
|
sed -i 's/^# SMTP_SECURE=false$/SMTP_SECURE=false/' "$ENV_FILE"
|
||||||
|
sed -i 's/^# SMTP_USER=cmlite@bnkops.ca$/SMTP_USER=cmlite@bnkops.ca/' "$ENV_FILE"
|
||||||
|
sed -i 's/^# SMTP_PASS=QMLDV1E2MWDHNJMY$/SMTP_PASS=QMLDV1E2MWDHNJMY/' "$ENV_FILE"
|
||||||
|
sed -i 's/^# SMTP_FROM_EMAIL=cmlite@bnkops.ca$/SMTP_FROM_EMAIL=cmlite@bnkops.ca/' "$ENV_FILE"
|
||||||
|
sed -i 's/^# SMTP_FROM_NAME="BNKops Influence Campaign"$/SMTP_FROM_NAME="BNKops Influence Campaign"/' "$ENV_FILE"
|
||||||
|
|
||||||
|
# Update other settings
|
||||||
|
sed -i 's/^NODE_ENV=development$/NODE_ENV=production/' "$ENV_FILE"
|
||||||
|
sed -i 's/^EMAIL_TEST_MODE=true$/EMAIL_TEST_MODE=false/' "$ENV_FILE"
|
||||||
|
sed -i 's|^APP_URL=http://localhost:3333$|APP_URL=https://influence.cmlite.org|' "$ENV_FILE"
|
||||||
|
|
||||||
|
echo -e "${GREEN}✓ Configured for PRODUCTION mode${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}Production Configuration:${NC}"
|
||||||
|
echo " • SMTP: ProtonMail (smtp.protonmail.ch)"
|
||||||
|
echo " • Email Test Mode: Disabled"
|
||||||
|
echo " • Node Environment: production"
|
||||||
|
echo " • App URL: https://influence.cmlite.org"
|
||||||
|
echo ""
|
||||||
|
echo -e "${RED}⚠️ IMPORTANT: Emails will be sent to REAL recipients!${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${YELLOW}Next Steps:${NC}"
|
||||||
|
echo " 1. Restart the application:"
|
||||||
|
echo -e " ${BLUE}docker compose down && docker compose up -d${NC}"
|
||||||
|
echo ""
|
||||||
|
echo " 2. Verify SMTP connection:"
|
||||||
|
echo -e " ${BLUE}docker compose logs -f${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${GREEN}════════════════════════════════════════════════${NC}"
|
||||||
|
echo -e "${GREEN}✓ Configuration updated successfully!${NC}"
|
||||||
|
echo -e "${GREEN}════════════════════════════════════════════════${NC}"
|
||||||
|
echo ""
|
||||||
|
echo "Current settings:"
|
||||||
|
grep "^SMTP_HOST=" "$ENV_FILE"
|
||||||
|
grep "^NODE_ENV=" "$ENV_FILE"
|
||||||
|
grep "^EMAIL_TEST_MODE=" "$ENV_FILE"
|
||||||
|
grep "^APP_URL=" "$ENV_FILE"
|
||||||
|
echo ""
|
||||||
|
echo -e "Backup saved at: ${BLUE}$BACKUP_FILE${NC}"
|
||||||
0
mkdocs/.cache/.gitkeep
Executable file
0
mkdocs/.cache/plugin/social/09bbbc93a961c0990fa7e3217673978f.png
Normal file → Executable file
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 71 KiB |
0
mkdocs/.cache/plugin/social/10a5546a448a8a0b16de3eb978f8a68f.png
Normal file → Executable file
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
0
mkdocs/.cache/plugin/social/2030a47afa1104093ebf519a6f22a7d1.png
Normal file → Executable file
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 65 KiB |
0
mkdocs/.cache/plugin/social/3df345a41836bfa1f24aa074839aff71.png
Normal file → Executable file
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
0
mkdocs/.cache/plugin/social/461dbf70704556ebdba00d9b93fdd71a.png
Normal file → Executable file
|
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 70 KiB |
0
mkdocs/.cache/plugin/social/499785a5782a92d89dee51c0bf8b6995.png
Normal file → Executable file
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 65 KiB |
0
mkdocs/.cache/plugin/social/49f28fa8303f79b46bfb7904c8e551a1.png
Normal file → Executable file
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 67 KiB |
0
mkdocs/.cache/plugin/social/4fec9fd5349ccccf8012393802a1a5bd.png
Normal file → Executable file
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 73 KiB |
0
mkdocs/.cache/plugin/social/513e74590c0aaa12f169c3f283993a05.png
Normal file → Executable file
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
0
mkdocs/.cache/plugin/social/5a026625721699a22ed4902c86e27264.png
Normal file → Executable file
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 76 KiB |
0
mkdocs/.cache/plugin/social/5c8323641288ce96dac5e5d0c03d1d88.png
Normal file → Executable file
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
0
mkdocs/.cache/plugin/social/5de16fced5aba77a2bd09132eb5fda0d.png
Normal file → Executable file
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 89 KiB |
BIN
mkdocs/site/assets/images/social/blog/posts/2.png → mkdocs/.cache/plugin/social/62372a9d5b433f883e1358d69aafb99d.png
Normal file → Executable file
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
0
mkdocs/.cache/plugin/social/630ed53169b0d638a0ecbc5a43b36dd5.png
Normal file → Executable file
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 69 KiB |
0
mkdocs/.cache/plugin/social/63fe0d7764ab46b6b1a896c92f5f08ad.png
Normal file → Executable file
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
0
mkdocs/.cache/plugin/social/6e0a466e141c6410aa3b931db727ad5a.png
Normal file → Executable file
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
0
mkdocs/.cache/plugin/social/6ff7c9a84364b85f150bfe85d21a1db8.png
Normal file → Executable file
|
Before Width: | Height: | Size: 62 KiB After Width: | Height: | Size: 62 KiB |
0
mkdocs/.cache/plugin/social/72eda47b0bb6ddeeba9c715ee9d857ab.png
Normal file → Executable file
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |
0
mkdocs/.cache/plugin/social/7b06061b4b9b4a82384b4b9cf809471d.png
Normal file → Executable file
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
0
mkdocs/.cache/plugin/social/7ca622286d4c40d181cd6c809308aadd.png
Normal file → Executable file
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 67 KiB |
0
mkdocs/.cache/plugin/social/7cc7e1ec8732cd69b83aa549bfb13cc3.png
Normal file → Executable file
|
Before Width: | Height: | Size: 66 KiB After Width: | Height: | Size: 66 KiB |
0
mkdocs/.cache/plugin/social/89cb9170565057569d85b76ef729d173.png
Normal file → Executable file
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 69 KiB |
0
mkdocs/.cache/plugin/social/8b4d2b2992e85f6cc7dcfc9a7eb7c502.png
Normal file → Executable file
|
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 69 KiB |
0
mkdocs/.cache/plugin/social/8e08f754f4d8c04a82391ae575aafaaa.png
Normal file → Executable file
|
Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB |