From cd19f8c0b963ac8fe1543dc0c990f454fbe3c0d2 Mon Sep 17 00:00:00 2001 From: bunker-admin Date: Mon, 16 Feb 2026 19:27:45 -0700 Subject: [PATCH] .env example --- .env.example | 251 +++++++++++++++ .gitignore | 10 + admin/.env.example | 9 + admin/src/components/map/AreaImportWizard.tsx | 271 ++++++++++++++-- .../src/pages/volunteer/VolunteerMapPage.tsx | 2 +- admin/src/types/api.ts | 1 + .../map/locations/area-import.service.ts | 19 +- .../modules/map/locations/overpass.service.ts | 14 +- media-manager/.env.example | 291 ++++++++++++++++++ 9 files changed, 836 insertions(+), 32 deletions(-) create mode 100644 .env.example create mode 100644 admin/.env.example create mode 100644 media-manager/.env.example diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cf21b6d --- /dev/null +++ b/.env.example @@ -0,0 +1,251 @@ +# ============================================================================== +# Changemaker Lite v2 — Environment Variables +# Copy this file to .env and fill in the values +# Generate secrets with: openssl rand -hex 32 +# ============================================================================== +# +# SECURITY WARNING: +# - All passwords marked REQUIRED_STRONG_PASSWORD_CHANGE_THIS MUST be changed +# - Use strong, unique passwords (20+ characters recommended) +# - Generate secrets with: openssl rand -hex 32 +# - NEVER commit .env to version control +# ============================================================================== + +# --- General --- +NODE_ENV=development +# Root domain serves MkDocs documentation site only +# All application routes (admin + public) accessible at app.${DOMAIN} +DOMAIN=cmlite.org +USER_ID=1000 +GROUP_ID=1000 +DOCKER_GROUP_ID=984 + +# --- V2 PostgreSQL --- +V2_POSTGRES_USER=changemaker +V2_POSTGRES_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS +V2_POSTGRES_DB=changemaker_v2 +V2_POSTGRES_PORT=5433 + +# --- JWT Auth --- +JWT_ACCESS_SECRET=GENERATE_WITH_openssl_rand_hex_32 +JWT_REFRESH_SECRET=GENERATE_WITH_openssl_rand_hex_32 +JWT_ACCESS_EXPIRY=15m +JWT_REFRESH_EXPIRY=7d + +# Encryption key for DB-stored secrets (SMTP password, etc.) +# REQUIRED in production — must NOT reuse JWT_ACCESS_SECRET +# Generate with: openssl rand -hex 32 +ENCRYPTION_KEY=GENERATE_WITH_openssl_rand_hex_32 + +# --- Initial Super Admin User (auto-created during database seeding) --- +# These credentials are used to create the initial super admin account +# Change these before running the seed script in production +INITIAL_ADMIN_EMAIL=admin@cmlite.org +INITIAL_ADMIN_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS + +# --- API --- +API_PORT=4000 +API_URL=http://localhost:4000 +CORS_ORIGINS=http://localhost:3000,http://localhost + +# --- Admin GUI --- +ADMIN_PORT=3000 +ADMIN_URL=http://localhost:3000 + +# --- Nginx --- +NGINX_HTTP_PORT=80 +NGINX_HTTPS_PORT=443 + +# --- SMTP / Email --- +SMTP_HOST=mailhog-changemaker +SMTP_PORT=1025 +SMTP_USER= +SMTP_PASS= +SMTP_FROM=noreply@cmlite.org +SMTP_FROM_NAME=Changemaker Lite +EMAIL_TEST_MODE=true +TEST_EMAIL_RECIPIENT=admin@cmlite.org + +# --- Listmonk --- +LISTMONK_PORT=9001 +LISTMONK_DB_PORT=5432 +LISTMONK_DB_USER=listmonk +LISTMONK_DB_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS +LISTMONK_DB_NAME=listmonk +# Web admin login (for the Listmonk dashboard at :9001) +LISTMONK_WEB_ADMIN_USER=admin +LISTMONK_WEB_ADMIN_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS +# API user (auto-created by listmonk-init container, used by V2 API for sync) +# Generate token: openssl rand -hex 16 +LISTMONK_API_USER=v2-api +LISTMONK_API_TOKEN=GENERATE_WITH_openssl_rand_hex_16 +LISTMONK_ADMIN_USER=v2-api +LISTMONK_ADMIN_PASSWORD=SAME_AS_LISTMONK_API_TOKEN +LISTMONK_SYNC_ENABLED=false +LISTMONK_PROXY_PORT=9002 +# Listmonk SMTP — MailHog for development (production SMTP added as second provider if credentials set) +LISTMONK_SMTP_HOST=mailhog-changemaker +LISTMONK_SMTP_PORT=1025 +LISTMONK_SMTP_USER= +LISTMONK_SMTP_PASSWORD= +LISTMONK_SMTP_TLS_TYPE=none +LISTMONK_SMTP_FROM=Changemaker Lite +# Production SMTP (uncomment and set for real email delivery): +# LISTMONK_SMTP_HOST=smtp.protonmail.ch +# LISTMONK_SMTP_PORT=587 +# LISTMONK_SMTP_USER=your@email.com +# LISTMONK_SMTP_PASSWORD=your-password +# LISTMONK_SMTP_TLS_TYPE=STARTTLS + +# --- Represent API (Canadian electoral data) --- +REPRESENT_API_URL=https://represent.opennorth.ca + +# --- NocoDB v2 (read-only data browser) --- +# NocoDB uses its own database (nocodb_meta) to avoid conflicts with Prisma +# The database is auto-created by init-nocodb-db.sh on first PostgreSQL startup +NOCODB_V2_PORT=8091 +NOCODB_URL=http://changemaker-v2-nocodb:8080 +NOCODB_PORT=8091 +NC_ADMIN_EMAIL=admin@cmlite.org +NC_ADMIN_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS + +# --- Redis --- +# Shared Redis (v2 uses authenticated connection) +REDIS_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS +REDIS_URL=redis://:${REDIS_PASSWORD}@redis-changemaker:6379 + +# --- Media Management --- +ENABLE_MEDIA_FEATURES=false +MEDIA_API_PORT=4100 +MEDIA_API_PUBLIC_URL=http://media-api:4100 +MEDIA_ROOT=/media/library +MEDIA_UPLOADS=/media/uploads +MAX_UPLOAD_SIZE_GB=10 +VIDEO_PLAYER_DEBUG=false + +# Video Analytics (Feb 2026) +VIDEO_ANALYTICS_RETENTION_DAYS=90 +VIDEO_ANALYTICS_IP_HASHING_ENABLED=true + +# Video Scheduling (Feb 2026) +VIDEO_SCHEDULE_DEFAULT_TIMEZONE=UTC +VIDEO_SCHEDULE_NOTIFICATION_ENABLED=true + +# Preview Links (Feb 2026) +VIDEO_PREVIEW_LINK_EXPIRY_HOURS=24 + +# --- Gitea --- +GITEA_URL=http://gitea-changemaker:3000 +GITEA_PORT=3030 +GITEA_WEB_PORT=3030 +GITEA_SSH_PORT=2222 +GITEA_DB_TYPE=mysql +GITEA_DB_HOST=gitea-db:3306 +GITEA_DB_NAME=gitea +GITEA_DB_USER=gitea +GITEA_DB_PASSWD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS +GITEA_DB_ROOT_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS +GITEA_ROOT_URL=https://git.cmlite.org +GITEA_DOMAIN=git.cmlite.org + +# --- n8n --- +N8N_URL=http://n8n-changemaker:5678 +N8N_PORT=5678 +N8N_HOST=n8n.cmlite.org +N8N_ENCRYPTION_KEY=REQUIRED_STRONG_PASSWORD_CHANGE_THIS +N8N_USER_EMAIL=admin@example.com +N8N_USER_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS +GENERIC_TIMEZONE=UTC + +# --- MkDocs --- +# Port mapping for MkDocs container (host:container) +# This also controls the Vite dev proxy in local development +# Change this port to use a different local port, and the admin dev server will automatically use it +MKDOCS_PORT=4003 +MKDOCS_SITE_SERVER_PORT=4001 +BASE_DOMAIN=https://cmlite.org +MKDOCS_PREVIEW_URL=http://mkdocs:8000 +MKDOCS_DOCS_PATH=/mkdocs/docs + +# --- Code Server --- +CODE_SERVER_PORT=8888 +CODE_SERVER_URL=http://code-server:8080 +USER_NAME=coder + +# --- Homepage --- +HOMEPAGE_PORT=3010 +HOMEPAGE_VAR_BASE_URL=http://localhost + +# --- Mini QR --- +MINI_QR_PORT=8089 +MINI_QR_URL=http://mini-qr:8080 +MINI_QR_EMBED_PORT=8885 + +# --- Excalidraw (Collaborative Whiteboard) --- +EXCALIDRAW_PORT=8090 +EXCALIDRAW_URL=http://excalidraw-changemaker:80 +EXCALIDRAW_EMBED_PORT=8886 +EXCALIDRAW_WS_URL=wss://draw.cmlite.org + +# --- MailHog --- +MAILHOG_SMTP_PORT=1025 +MAILHOG_WEB_PORT=8025 + +# --- NAR (National Address Register) --- +# Path to extracted NAR data (contains YYYYMM/Addresses/ and YYYYMM/Locations/) +# Download from: https://www150.statcan.gc.ca/n1/pub/46-26-0002/462600022022001-eng.htm +NAR_DATA_DIR=/data + +# --- Overpass / Area Import --- +# OpenStreetMap Overpass API endpoint (use a private instance for heavy usage) +OVERPASS_API_URL=https://overpass-api.de/api/interpreter +# Minimum delay between Overpass requests (ms) — public API requires 30s +OVERPASS_MIN_DELAY_MS=30000 +# Maximum reverse geocode grid points for area import fill-in +AREA_IMPORT_MAX_GRID_POINTS=500 + +# --- Geocoding --- +# Optional Mapbox API key for improved geocoding accuracy +# Free tier: 100,000 requests/month +# Sign up: https://www.mapbox.com/pricing +MAPBOX_API_KEY= +# Rate limit delay between provider requests (milliseconds) +GEOCODING_RATE_LIMIT_MS=1100 +# Redis-backed persistent cache settings +GEOCODING_CACHE_ENABLED=true +GEOCODING_CACHE_TTL_HOURS=24 +# Phase 2: Performance & Accuracy +# Google Maps API (optional, most accurate but costs $0.005/request after 100k/month) +GOOGLE_MAPS_API_KEY= +GOOGLE_MAPS_ENABLED=false +# Parallel geocoding for bulk imports (10x speedup) +GEOCODING_PARALLEL_ENABLED=true +GEOCODING_BATCH_SIZE=10 + +# Bulk Re-Geocoding (Phase 3) +BULK_GEOCODE_ENABLED=true +BULK_GEOCODE_MAX_BATCH=5000 + +# --- Pangolin Tunnel --- +# Server: self-hosted Pangolin instance +PANGOLIN_API_URL=https://api.bnkserve.org/v1 +PANGOLIN_API_KEY= +PANGOLIN_ORG_ID= +# Populated after setup (via admin GUI or API) +PANGOLIN_SITE_ID= +PANGOLIN_ENDPOINT=https://pangolin.bnkserve.org +PANGOLIN_NEWT_ID= +PANGOLIN_NEWT_SECRET= + +# --- Monitoring (only used with --profile monitoring) --- +PROMETHEUS_PORT=9090 +GRAFANA_PORT=3001 +GRAFANA_ADMIN_PASSWORD=admin +GRAFANA_ROOT_URL=http://localhost:3001 +CADVISOR_PORT=8080 +NODE_EXPORTER_PORT=9100 +REDIS_EXPORTER_PORT=9121 +ALERTMANAGER_PORT=9093 +GOTIFY_PORT=8889 +GOTIFY_ADMIN_USER=admin +GOTIFY_ADMIN_PASSWORD=admin diff --git a/.gitignore b/.gitignore index 31ec6a5..3ecb3b2 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ node_modules/ .env .env* +!.env.example /configs/cloudflare/*.json /configs/cloudflare/*.yaml @@ -34,3 +35,12 @@ node_modules/ # NAR data directory (large voter registry files) /data/ + +# Media files (managed by Docker volumes, not git) +/media/ + +# Build output +/admin/dist/ + +# MkDocs core binary +/mkdocs/core diff --git a/admin/.env.example b/admin/.env.example new file mode 100644 index 0000000..9edaec1 --- /dev/null +++ b/admin/.env.example @@ -0,0 +1,9 @@ +# API Configuration +# For Docker: http://changemaker-v2-api:4000 +# For local dev: http://localhost:4000 +VITE_API_URL=http://localhost:4000 + +# MkDocs Configuration +# For Docker: http://mkdocs-changemaker:8000 +# For local dev: http://localhost:4003 +VITE_MKDOCS_URL=http://localhost:4003 diff --git a/admin/src/components/map/AreaImportWizard.tsx b/admin/src/components/map/AreaImportWizard.tsx index bba7a12..ac6a086 100644 --- a/admin/src/components/map/AreaImportWizard.tsx +++ b/admin/src/components/map/AreaImportWizard.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef, useCallback } from 'react'; +import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { Steps, Button, @@ -26,7 +26,11 @@ import { CloseCircleOutlined, LoadingOutlined, MinusCircleOutlined, + WarningOutlined, } from '@ant-design/icons'; +import { MapContainer, TileLayer, Rectangle, Polygon, useMap } from 'react-leaflet'; +import type { LatLngBoundsExpression } from 'leaflet'; +import 'leaflet/dist/leaflet.css'; import { api } from '@/lib/api'; import type { Cut, @@ -51,6 +55,56 @@ const SOURCE_STATUS_ICONS: Record = { skipped: , }; +const ZOOM_PRESETS = [ + { value: 15, label: 'Neighborhood (zoom 15)' }, + { value: 13, label: 'District (zoom 13)' }, + { value: 10, label: 'City-wide (zoom 10)' }, + { value: 8, label: 'Region (zoom 8)' }, +]; + +/** + * Client-side bounding box calculation from center + zoom + viewport. + * Mirrors the server-side boundsFromCenterZoom() in spatial.ts. + */ +function boundsFromCenterZoom( + lat: number, + lng: number, + zoom: number, + viewportWidth = 1024, + viewportHeight = 768, +): { minLat: number; maxLat: number; minLng: number; maxLng: number } { + const metersPerPixel = (156543.03392 * Math.cos((lat * Math.PI) / 180)) / Math.pow(2, zoom); + const halfWidthMeters = (viewportWidth / 2) * metersPerPixel; + const halfHeightMeters = (viewportHeight / 2) * metersPerPixel; + const latDelta = halfHeightMeters / 111320; + const lngDelta = halfWidthMeters / (111320 * Math.cos((lat * Math.PI) / 180)); + return { + minLat: lat - latDelta, + maxLat: lat + latDelta, + minLng: lng - lngDelta, + maxLng: lng + lngDelta, + }; +} + +/** + * Approximate area in sq km from a bounding box. + */ +function areaSqKm(bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number }): number { + const latMeters = (bounds.maxLat - bounds.minLat) * 111320; + const avgLat = (bounds.minLat + bounds.maxLat) / 2; + const lngMeters = (bounds.maxLng - bounds.minLng) * 111320 * Math.cos((avgLat * Math.PI) / 180); + return (latMeters * lngMeters) / 1_000_000; +} + +/** Fit the map to given bounds whenever they change. */ +function FitBoundsUpdater({ bounds }: { bounds: LatLngBoundsExpression }) { + const map = useMap(); + useEffect(() => { + map.fitBounds(bounds, { padding: [20, 20] }); + }, [map, bounds]); + return null; +} + export default function AreaImportWizard({ cuts, onComplete }: AreaImportWizardProps) { const [currentStep, setCurrentStep] = useState(0); @@ -59,6 +113,7 @@ export default function AreaImportWizard({ cuts, onComplete }: AreaImportWizardP const [selectedCutId, setSelectedCutId] = useState(); const [mapSettings, setMapSettings] = useState(null); const [mapSettingsLoading, setMapSettingsLoading] = useState(false); + const [zoomOverride, setZoomOverride] = useState(null); // Step 1: Sources const [osmEnabled, setOsmEnabled] = useState(true); @@ -78,6 +133,9 @@ export default function AreaImportWizard({ cuts, onComplete }: AreaImportWizardP const [importing, setImporting] = useState(false); const pollRef = useRef>(undefined); + // Effective zoom for viewport mode (override or map settings default) + const effectiveZoom = zoomOverride ?? mapSettings?.zoom ?? 13; + // Load map settings for viewport mode useEffect(() => { if (areaType === 'viewport' && !mapSettings) { @@ -96,6 +154,54 @@ export default function AreaImportWizard({ cuts, onComplete }: AreaImportWizardP }; }, []); + // Compute viewport bounds for preview map + const viewportBounds = useMemo(() => { + if (areaType !== 'viewport' || !mapSettings?.latitude || !mapSettings?.longitude) return null; + return boundsFromCenterZoom( + Number(mapSettings.latitude), + Number(mapSettings.longitude), + effectiveZoom, + ); + }, [areaType, mapSettings?.latitude, mapSettings?.longitude, effectiveZoom]); + + const viewportAreaSqKm = viewportBounds ? areaSqKm(viewportBounds) : null; + + // Parse cut polygon for preview map + const selectedCutGeoJson = useMemo(() => { + if (areaType !== 'cut' || !selectedCutId) return null; + const cut = cuts.find((c) => c.id === selectedCutId); + if (!cut?.geojson) return null; + try { + const parsed = JSON.parse(cut.geojson); + // GeoJSON coordinates are [lng, lat] — Leaflet Polygon needs [lat, lng] + if (parsed.type === 'Polygon' && parsed.coordinates) { + return parsed.coordinates[0].map((c: number[]) => [c[1], c[0]] as [number, number]); + } + if (parsed.type === 'MultiPolygon' && parsed.coordinates) { + // Just use the first polygon ring for preview + return parsed.coordinates[0][0].map((c: number[]) => [c[1], c[0]] as [number, number]); + } + // If it's just an array of coordinate rings (legacy format) + if (Array.isArray(parsed) && Array.isArray(parsed[0])) { + return parsed[0].map((c: number[]) => [c[1], c[0]] as [number, number]); + } + } catch { /* ignore parse errors */ } + return null; + }, [areaType, selectedCutId, cuts]); + + // Cut bounds for fitBounds + const cutPreviewBounds = useMemo((): LatLngBoundsExpression | null => { + if (!selectedCutGeoJson || selectedCutGeoJson.length === 0) return null; + let minLat = Infinity, maxLat = -Infinity, minLng = Infinity, maxLng = -Infinity; + for (const [lat, lng] of selectedCutGeoJson) { + if (lat < minLat) minLat = lat; + if (lat > maxLat) maxLat = lat; + if (lng < minLng) minLng = lng; + if (lng > maxLng) maxLng = lng; + } + return [[minLat, minLng], [maxLat, maxLng]]; + }, [selectedCutGeoJson]); + const buildRequestBody = useCallback(() => { const sources: Record = { osm: osmEnabled, @@ -114,11 +220,11 @@ export default function AreaImportWizard({ cuts, onComplete }: AreaImportWizardP lat: mapSettings?.latitude ? Number(mapSettings.latitude) : 53.5, lng: mapSettings?.longitude ? Number(mapSettings.longitude) : -113.5, }; - body.zoom = mapSettings?.zoom ?? 13; + body.zoom = effectiveZoom; } return body; - }, [areaType, selectedCutId, mapSettings, osmEnabled, narEnabled, narResidentialOnly, rgEnabled, rgSpacing, rgMaxPoints]); + }, [areaType, selectedCutId, mapSettings, effectiveZoom, osmEnabled, narEnabled, narResidentialOnly, rgEnabled, rgSpacing, rgMaxPoints]); const fetchPreview = async () => { setPreviewLoading(true); @@ -162,6 +268,94 @@ export default function AreaImportWizard({ cuts, onComplete }: AreaImportWizardP const canProceedStep0 = areaType === 'cut' ? !!selectedCutId : (!!mapSettings?.latitude && !!mapSettings?.longitude); const canProceedStep1 = osmEnabled || narEnabled || rgEnabled; + // ─── Mini Map Preview ───────────────────────────────────────────── + + const renderPreviewMap = () => { + // Viewport mode: show rectangle + if (areaType === 'viewport' && viewportBounds) { + const mapBounds: LatLngBoundsExpression = [ + [viewportBounds.minLat, viewportBounds.minLng], + [viewportBounds.maxLat, viewportBounds.maxLng], + ]; + return ( + +
+ + + + + +
+
+ ); + } + + // Cut mode: show polygon + if (areaType === 'cut' && selectedCutGeoJson && cutPreviewBounds) { + return ( + +
+ + + + + +
+
+ ); + } + + // No area configured yet + return ( + + + Select an area type above to see preview + + + ); + }; + + // ─── Steps ───────────────────────────────────────────────────────── + const steps = [ { title: 'Define Area', @@ -202,22 +396,56 @@ export default function AreaImportWizard({ cuts, onComplete }: AreaImportWizardP {mapSettingsLoading ? ( ) : mapSettings?.latitude && mapSettings?.longitude ? ( - - - - - - - - - - - - - - A bounding box will be derived from the map center and zoom level. - - + <> + + + + + + + + + + + + + + + + + +
+ Zoom Level Override: +