From 1a1f12c45b5232243355a030fad4da75e5297aba Mon Sep 17 00:00:00 2001 From: bunker-admin Date: Wed, 18 Feb 2026 17:15:31 -0700 Subject: [PATCH] Tonne of updates --- .env.example | 60 +- .gitignore | 8 + FEDERATION_PLAN.md | 257 ++++++ admin/src/App.tsx | 29 + admin/src/components/AppLayout.tsx | 4 + admin/src/components/GrapesJSEditor.tsx | 27 + admin/src/components/PublicLayout.tsx | 38 + admin/src/components/VolunteerFooterNav.tsx | 14 +- .../canvass/CutCampaignAnalyticsCard.tsx | 123 +++ .../canvass/ExportContactsModal.tsx | 297 +++++++ .../components/dashboard/ActivityFeedCard.tsx | 145 ++++ .../components/dashboard/ChatNotifierCard.tsx | 120 +++ .../components/dashboard/TodayEventsCard.tsx | 108 +++ admin/src/pages/CampaignsPage.tsx | 32 + admin/src/pages/CanvassDashboardPage.tsx | 29 +- admin/src/pages/DashboardPage.tsx | 474 ++++++----- admin/src/pages/GancioPage.tsx | 127 +++ admin/src/pages/ListmonkPage.tsx | 37 +- admin/src/pages/NocoDBPage.tsx | 50 +- admin/src/pages/RocketChatPage.tsx | 208 +++++ admin/src/pages/SettingsPage.tsx | 83 ++ admin/src/pages/VaultwardenPage.tsx | 160 ++++ .../src/pages/volunteer/VolunteerChatPage.tsx | 125 +++ admin/src/types/api.ts | 112 +++ api/prisma/init-gancio-db.sh | 22 + .../migration.sql | 2 + .../migration.sql | 2 + .../migration.sql | 2 + api/prisma/schema.prisma | 14 + api/prisma/seed.ts | 102 +++ api/src/config/env.ts | 25 + api/src/modules/dashboard/dashboard.routes.ts | 47 ++ .../modules/dashboard/dashboard.service.ts | 305 ++++++++ .../influence/responses/responses.service.ts | 33 +- .../listmonk/listmonk-webhook.routes.ts | 69 ++ api/src/modules/listmonk/listmonk.routes.ts | 9 + .../map/canvass/canvass-export.routes.ts | 57 ++ .../map/canvass/canvass-export.schemas.ts | 21 + .../map/canvass/canvass-export.service.ts | 392 ++++++++++ .../modules/map/canvass/canvass.service.ts | 119 +++ api/src/modules/map/shifts/shifts.service.ts | 319 +++++++- .../modules/rocketchat/rocketchat.routes.ts | 81 ++ .../modules/rocketchat/rocketchat.service.ts | 129 +++ api/src/modules/services/services.routes.ts | 20 +- api/src/modules/settings/settings.schemas.ts | 11 + api/src/server.ts | 13 + api/src/services/email-queue.service.ts | 8 + api/src/services/email.service.ts | 254 ++++++ api/src/services/gancio.client.ts | 246 ++++++ .../services/listmonk-event-sync.service.ts | 153 ++++ api/src/services/listmonk-sync.service.ts | 8 +- .../services/notification-queue.service.ts | 228 ++++++ api/src/services/notification.helper.ts | 33 + .../services/rocketchat-webhook.service.ts | 78 ++ api/src/services/rocketchat.client.ts | 331 ++++++++ .../email/admin-response-submitted-alert.html | 112 +++ .../email/admin-response-submitted-alert.txt | 12 + .../email/admin-shift-cancellation-alert.html | 115 +++ .../email/admin-shift-cancellation-alert.txt | 13 + .../email/admin-shift-signup-alert.html | 115 +++ .../email/admin-shift-signup-alert.txt | 13 + .../email/admin-sign-requested-alert.html | 112 +++ .../email/admin-sign-requested-alert.txt | 12 + .../email/volunteer-cancellation-ack.html | 112 +++ .../email/volunteer-cancellation-ack.txt | 13 + .../email/volunteer-session-summary.html | 145 ++++ .../email/volunteer-session-summary.txt | 13 + api/src/utils/metrics.ts | 6 +- bunker-ops/.gitignore | 13 + bunker-ops/HOWTO.md | 519 ++++++++++++ bunker-ops/ROLLOUT_PLAN.md | 543 +++++++++++++ bunker-ops/ansible.cfg | 18 + bunker-ops/inventory/group_vars/all/main.yml | 29 + .../group_vars/changemaker_instances/main.yml | 52 ++ .../host_vars/example-instance/main.yml | 28 + .../host_vars/example-instance/vault.yml | 63 ++ bunker-ops/inventory/hosts.yml | 26 + bunker-ops/playbooks/backup.yml | 19 + bunker-ops/playbooks/configure.yml | 36 + bunker-ops/playbooks/deploy.yml | 43 + bunker-ops/playbooks/monitoring.yml | 18 + bunker-ops/playbooks/upgrade.yml | 78 ++ .../roles/changemaker/defaults/main.yml | 53 ++ .../roles/changemaker/handlers/main.yml | 18 + bunker-ops/roles/changemaker/tasks/backup.yml | 15 + bunker-ops/roles/changemaker/tasks/clone.yml | 21 + bunker-ops/roles/changemaker/tasks/deploy.yml | 54 ++ bunker-ops/roles/changemaker/tasks/dirs.yml | 20 + bunker-ops/roles/changemaker/tasks/env.yml | 14 + bunker-ops/roles/changemaker/tasks/health.yml | 38 + bunker-ops/roles/changemaker/tasks/main.yml | 30 + .../roles/changemaker/tasks/services.yml | 14 + bunker-ops/roles/changemaker/templates/env.j2 | 195 +++++ .../changemaker/templates/services.yaml.j2 | 127 +++ bunker-ops/roles/common/defaults/main.yml | 16 + bunker-ops/roles/common/handlers/main.yml | 14 + bunker-ops/roles/common/tasks/docker.yml | 42 + bunker-ops/roles/common/tasks/fail2ban.yml | 30 + bunker-ops/roles/common/tasks/main.yml | 29 + bunker-ops/roles/common/tasks/swap.yml | 36 + bunker-ops/roles/common/tasks/ufw.yml | 24 + bunker-ops/roles/monitoring/defaults/main.yml | 12 + bunker-ops/roles/monitoring/handlers/main.yml | 6 + bunker-ops/roles/monitoring/tasks/main.yml | 16 + .../monitoring/templates/prometheus.yml.j2 | 86 ++ bunker-ops/scripts/add-instance.sh | 113 +++ bunker-ops/scripts/bootstrap-vault.sh | 153 ++++ config.sh | 109 ++- configs/pangolin/resources.yml | 18 + docker-compose.yml | 221 +++++- .../social/assets/images/social/docs/phil.png | Bin 0 -> 63043 bytes mkdocs/.cache/plugin/social/manifest.json | 1 + mkdocs/docs/assets/js/env-config.js | 4 +- mkdocs/docs/assets/js/gancio-events.js | 131 ++++ .../repo-data/admin-changemaker.lite.json | 4 +- .../repo-data/anthropics-claude-code.json | 8 +- .../assets/repo-data/coder-code-server.json | 8 +- .../repo-data/gethomepage-homepage.json | 8 +- .../docs/assets/repo-data/go-gitea-gitea.json | 10 +- .../docs/assets/repo-data/knadh-listmonk.json | 6 +- mkdocs/docs/assets/repo-data/n8n-io-n8n.json | 10 +- .../docs/assets/repo-data/nocodb-nocodb.json | 10 +- .../docs/assets/repo-data/ollama-ollama.json | 8 +- .../repo-data/squidfunk-mkdocs-material.json | 6 +- mkdocs/docs/docs/deployment/index.md | 11 +- mkdocs/docs/docs/features/index.md | 90 +++ .../getting-started/environment-variables.md | 84 +- mkdocs/docs/docs/phil.md | 2 + mkdocs/docs/docs/services/index.md | 53 +- .../env_config_hook.cpython-311.pyc | Bin 6894 -> 7399 bytes mkdocs/docs/hooks/env_config_hook.py | 9 + mkdocs/docs/overrides/lander.html | 107 ++- mkdocs/mkdocs.yml | 1 + mkdocs/site/404.html | 65 ++ mkdocs/site/assets/css/payment-widgets.css | 54 ++ .../integrations/analytics/custom.png | Bin 0 -> 65590 bytes mkdocs/site/assets/js/env-config.js | 8 +- mkdocs/site/assets/js/payment-widgets.js | 251 ++++++ .../repo-data/admin-changemaker.lite.json | 4 +- .../repo-data/anthropics-claude-code.json | 8 +- .../assets/repo-data/coder-code-server.json | 8 +- .../repo-data/gethomepage-homepage.json | 8 +- .../site/assets/repo-data/go-gitea-gitea.json | 10 +- .../site/assets/repo-data/knadh-listmonk.json | 6 +- mkdocs/site/assets/repo-data/n8n-io-n8n.json | 10 +- .../site/assets/repo-data/nocodb-nocodb.json | 10 +- .../site/assets/repo-data/ollama-ollama.json | 8 +- .../repo-data/squidfunk-mkdocs-material.json | 6 +- mkdocs/site/blog/index.html | 65 ++ mkdocs/site/docs/admin/index.html | 67 ++ mkdocs/site/docs/api/index.html | 67 ++ mkdocs/site/docs/architecture/index.html | 67 ++ mkdocs/site/docs/deployment/index.html | 67 ++ mkdocs/site/docs/features/index.html | 67 ++ .../environment-variables/index.html | 67 ++ mkdocs/site/docs/getting-started/index.html | 67 ++ mkdocs/site/docs/index.html | 67 ++ mkdocs/site/docs/services/index.html | 67 ++ mkdocs/site/docs/troubleshooting/index.html | 67 ++ mkdocs/site/docs/volunteer/index.html | 67 ++ .../env_config_hook.cpython-311.pyc | Bin 5702 -> 6894 bytes mkdocs/site/hooks/env_config_hook.py | 34 +- mkdocs/site/index.html | 105 ++- mkdocs/site/lander/index.html | 105 ++- mkdocs/site/main/index.html | 67 ++ mkdocs/site/overrides/lander.html | 105 ++- .../integrations/analytics/custom.html | 55 ++ .../integrations/analytics/custom/index.html | 68 ++ mkdocs/site/search/search_index.json | 2 +- mkdocs/site/sitemap.xml | 4 + mkdocs/site/sitemap.xml.gz | Bin 303 -> 329 bytes mkdocs/site/test/index.html | 213 +++++ nginx/conf.d/services.conf | 111 +++ nginx/conf.d/services.conf.template | 111 +++ scripts/backup.sh | 24 + scripts/upgrade.sh | 739 ++++++++++++++++++ 176 files changed, 12786 insertions(+), 388 deletions(-) create mode 100644 FEDERATION_PLAN.md create mode 100644 admin/src/components/canvass/CutCampaignAnalyticsCard.tsx create mode 100644 admin/src/components/canvass/ExportContactsModal.tsx create mode 100644 admin/src/components/dashboard/ActivityFeedCard.tsx create mode 100644 admin/src/components/dashboard/ChatNotifierCard.tsx create mode 100644 admin/src/components/dashboard/TodayEventsCard.tsx create mode 100644 admin/src/pages/GancioPage.tsx create mode 100644 admin/src/pages/RocketChatPage.tsx create mode 100644 admin/src/pages/VaultwardenPage.tsx create mode 100644 admin/src/pages/volunteer/VolunteerChatPage.tsx create mode 100755 api/prisma/init-gancio-db.sh create mode 100644 api/prisma/migrations/20260218000000_add_shift_cancellation_notification/migration.sql create mode 100644 api/prisma/migrations/20260218100000_add_gancio_event_id/migration.sql create mode 100644 api/prisma/migrations/20260218200000_add_enable_events_setting/migration.sql create mode 100644 api/src/modules/listmonk/listmonk-webhook.routes.ts create mode 100644 api/src/modules/map/canvass/canvass-export.routes.ts create mode 100644 api/src/modules/map/canvass/canvass-export.schemas.ts create mode 100644 api/src/modules/map/canvass/canvass-export.service.ts create mode 100644 api/src/modules/rocketchat/rocketchat.routes.ts create mode 100644 api/src/modules/rocketchat/rocketchat.service.ts create mode 100644 api/src/services/gancio.client.ts create mode 100644 api/src/services/listmonk-event-sync.service.ts create mode 100644 api/src/services/notification-queue.service.ts create mode 100644 api/src/services/notification.helper.ts create mode 100644 api/src/services/rocketchat-webhook.service.ts create mode 100644 api/src/services/rocketchat.client.ts create mode 100644 api/src/templates/email/admin-response-submitted-alert.html create mode 100644 api/src/templates/email/admin-response-submitted-alert.txt create mode 100644 api/src/templates/email/admin-shift-cancellation-alert.html create mode 100644 api/src/templates/email/admin-shift-cancellation-alert.txt create mode 100644 api/src/templates/email/admin-shift-signup-alert.html create mode 100644 api/src/templates/email/admin-shift-signup-alert.txt create mode 100644 api/src/templates/email/admin-sign-requested-alert.html create mode 100644 api/src/templates/email/admin-sign-requested-alert.txt create mode 100644 api/src/templates/email/volunteer-cancellation-ack.html create mode 100644 api/src/templates/email/volunteer-cancellation-ack.txt create mode 100644 api/src/templates/email/volunteer-session-summary.html create mode 100644 api/src/templates/email/volunteer-session-summary.txt create mode 100644 bunker-ops/.gitignore create mode 100644 bunker-ops/HOWTO.md create mode 100644 bunker-ops/ROLLOUT_PLAN.md create mode 100644 bunker-ops/ansible.cfg create mode 100644 bunker-ops/inventory/group_vars/all/main.yml create mode 100644 bunker-ops/inventory/group_vars/changemaker_instances/main.yml create mode 100644 bunker-ops/inventory/host_vars/example-instance/main.yml create mode 100644 bunker-ops/inventory/host_vars/example-instance/vault.yml create mode 100644 bunker-ops/inventory/hosts.yml create mode 100644 bunker-ops/playbooks/backup.yml create mode 100644 bunker-ops/playbooks/configure.yml create mode 100644 bunker-ops/playbooks/deploy.yml create mode 100644 bunker-ops/playbooks/monitoring.yml create mode 100644 bunker-ops/playbooks/upgrade.yml create mode 100644 bunker-ops/roles/changemaker/defaults/main.yml create mode 100644 bunker-ops/roles/changemaker/handlers/main.yml create mode 100644 bunker-ops/roles/changemaker/tasks/backup.yml create mode 100644 bunker-ops/roles/changemaker/tasks/clone.yml create mode 100644 bunker-ops/roles/changemaker/tasks/deploy.yml create mode 100644 bunker-ops/roles/changemaker/tasks/dirs.yml create mode 100644 bunker-ops/roles/changemaker/tasks/env.yml create mode 100644 bunker-ops/roles/changemaker/tasks/health.yml create mode 100644 bunker-ops/roles/changemaker/tasks/main.yml create mode 100644 bunker-ops/roles/changemaker/tasks/services.yml create mode 100644 bunker-ops/roles/changemaker/templates/env.j2 create mode 100644 bunker-ops/roles/changemaker/templates/services.yaml.j2 create mode 100644 bunker-ops/roles/common/defaults/main.yml create mode 100644 bunker-ops/roles/common/handlers/main.yml create mode 100644 bunker-ops/roles/common/tasks/docker.yml create mode 100644 bunker-ops/roles/common/tasks/fail2ban.yml create mode 100644 bunker-ops/roles/common/tasks/main.yml create mode 100644 bunker-ops/roles/common/tasks/swap.yml create mode 100644 bunker-ops/roles/common/tasks/ufw.yml create mode 100644 bunker-ops/roles/monitoring/defaults/main.yml create mode 100644 bunker-ops/roles/monitoring/handlers/main.yml create mode 100644 bunker-ops/roles/monitoring/tasks/main.yml create mode 100644 bunker-ops/roles/monitoring/templates/prometheus.yml.j2 create mode 100755 bunker-ops/scripts/add-instance.sh create mode 100755 bunker-ops/scripts/bootstrap-vault.sh create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/phil.png create mode 100644 mkdocs/docs/assets/js/gancio-events.js create mode 100644 mkdocs/docs/docs/phil.md create mode 100644 mkdocs/site/assets/css/payment-widgets.css create mode 100644 mkdocs/site/assets/images/social/partials/integrations/analytics/custom.png create mode 100644 mkdocs/site/assets/js/payment-widgets.js create mode 100644 mkdocs/site/overrides/partials/integrations/analytics/custom.html create mode 100644 mkdocs/site/partials/integrations/analytics/custom/index.html create mode 100755 scripts/upgrade.sh diff --git a/.env.example b/.env.example index 1d6a418e..f2b5f1a9 100644 --- a/.env.example +++ b/.env.example @@ -69,7 +69,8 @@ TEST_EMAIL_RECIPIENT=admin@cmlite.org # --- Listmonk --- LISTMONK_PORT=9001 -LISTMONK_DB_PORT=5432 +# Use 5434 to avoid conflict with main PostgreSQL (5432 internal / 5433 host) +LISTMONK_DB_PORT=5434 LISTMONK_DB_USER=listmonk LISTMONK_DB_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS LISTMONK_DB_NAME=listmonk @@ -83,6 +84,7 @@ 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_WEBHOOK_SECRET= LISTMONK_PROXY_PORT=9002 # Listmonk SMTP — MailHog for development (production SMTP added as second provider if credentials set) LISTMONK_SMTP_HOST=mailhog-changemaker @@ -124,9 +126,12 @@ ENABLE_PAYMENTS=false ENABLE_MEDIA_FEATURES=false MEDIA_API_PORT=4100 MEDIA_API_PUBLIC_URL=http://media-api:4100 +# Used during admin Docker build to set the media API endpoint for Vite +VITE_MEDIA_API_URL=http://changemaker-media-api:4100 MEDIA_ROOT=/media/library MEDIA_UPLOADS=/media/uploads MAX_UPLOAD_SIZE_GB=10 +PUBLIC_MEDIA_PORT=3100 VIDEO_PLAYER_DEBUG=false # Video Analytics (Feb 2026) @@ -168,7 +173,7 @@ GENERIC_TIMEZONE=UTC # 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 +MKDOCS_SITE_SERVER_PORT=4004 BASE_DOMAIN=https://cmlite.org MKDOCS_PREVIEW_URL=http://mkdocs:8000 MKDOCS_DOCS_PATH=/mkdocs/docs @@ -194,6 +199,21 @@ EXCALIDRAW_URL=http://excalidraw-changemaker:80 EXCALIDRAW_EMBED_PORT=8886 EXCALIDRAW_WS_URL=wss://draw.cmlite.org +# --- Vaultwarden (Password Manager) --- +VAULTWARDEN_PORT=8445 +VAULTWARDEN_URL=http://vaultwarden-changemaker:80 +VAULTWARDEN_EMBED_PORT=8890 +# Admin panel token (access at /admin) — generate with: openssl rand -hex 32 +VAULTWARDEN_ADMIN_TOKEN= +# MUST use HTTPS — Bitwarden web vault enforces HTTPS for account creation +# Set to your Pangolin tunnel URL (e.g., https://vault.yourdomain.org) +# Local access (browsing existing vault) works on HTTP, but signup/invite requires HTTPS +VAULTWARDEN_DOMAIN=https://vault.cmlite.org +VAULTWARDEN_SIGNUPS_ALLOWED=false +VAULTWARDEN_WEBSOCKET_ENABLED=true +# SMTP security: "off" for MailHog, "starttls" or "force_tls" for production +VAULTWARDEN_SMTP_SECURITY=off + # --- MailHog --- MAILHOG_SMTP_PORT=1025 MAILHOG_WEB_PORT=8025 @@ -244,15 +264,45 @@ PANGOLIN_ENDPOINT=https://pangolin.bnkserve.org PANGOLIN_NEWT_ID= PANGOLIN_NEWT_SECRET= +# --- Prisma CLI (host-side only, NOT used by Docker containers) --- +# Containers resolve the DB hostname internally via docker-compose environment +# This is used when running `npx prisma migrate dev` from the host machine +DATABASE_URL=postgresql://changemaker:YOUR_POSTGRES_PASSWORD@localhost:5433/changemaker_v2 + +# --- Rocket.Chat (Team Chat) --- +# ENABLE_CHAT is the initial default; once saved in admin Settings, the DB value is authoritative +ENABLE_CHAT=false +ROCKETCHAT_ADMIN_USER=rcadmin +ROCKETCHAT_ADMIN_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS +ROCKETCHAT_URL=http://rocketchat-changemaker:3000 +ROCKETCHAT_EMBED_PORT=8891 + +# --- Gancio (Event Management) --- +# Uses shared PostgreSQL (database: gancio, auto-created by init-gancio-db.sh) +GANCIO_PORT=8092 +GANCIO_URL=http://gancio-changemaker:13120 +GANCIO_EMBED_PORT=8892 +GANCIO_BASE_URL=https://events.cmlite.org +# Gancio admin credentials for shift-to-event sync (OAuth login) +GANCIO_ADMIN_USER=admin +GANCIO_ADMIN_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS +# Enable automatic shift → Gancio event sync +GANCIO_SYNC_ENABLED=false + # --- Monitoring (only used with --profile monitoring) --- PROMETHEUS_PORT=9090 -GRAFANA_PORT=3001 +GRAFANA_PORT=3005 GRAFANA_ADMIN_PASSWORD=admin -GRAFANA_ROOT_URL=http://localhost:3001 -CADVISOR_PORT=8080 +GRAFANA_ROOT_URL=http://localhost:3005 +CADVISOR_PORT=8086 NODE_EXPORTER_PORT=9100 REDIS_EXPORTER_PORT=9121 ALERTMANAGER_PORT=9093 GOTIFY_PORT=8889 GOTIFY_ADMIN_USER=admin GOTIFY_ADMIN_PASSWORD=admin + +# --- Bunker Ops (Fleet Management) --- +INSTANCE_LABEL= # Unique label for this instance (defaults to DOMAIN) +BUNKER_OPS_ENABLED=false # Enable remote metrics push to central server +BUNKER_OPS_REMOTE_WRITE_URL= # VictoriaMetrics remote_write endpoint (e.g., https://ops.example.com/api/v1/write) diff --git a/.gitignore b/.gitignore index 0948ea22..361f5ede 100644 --- a/.gitignore +++ b/.gitignore @@ -36,8 +36,16 @@ node_modules/ # Media files (managed by Docker volumes, not git) /media/ +# Ansible per-instance override (generated by Bunker Ops) +docker-compose.override.yml + # Build output /admin/dist/ # MkDocs core binary /mkdocs/core + +# Upgrade artifacts +/logs/ +/backups/ +.upgrade.lock diff --git a/FEDERATION_PLAN.md b/FEDERATION_PLAN.md new file mode 100644 index 00000000..38fea2ee --- /dev/null +++ b/FEDERATION_PLAN.md @@ -0,0 +1,257 @@ +# Phase 16: Federation — Instance-to-Instance Campaign Network + +## Context + +Changemaker Lite instances are currently isolated islands. This feature introduces a **federated discovery network** where any instance can act as a **hub** (accepting registrations, serving a directory) and/or a **spoke** (registering with hubs, sharing campaigns). The goal is organic, admin-to-admin networking with public campaign discoverability as a secondary benefit. + +**Design principles:** +- Any instance can be a hub, spoke, or both — no central authority +- Medium-depth campaign sharing: enough metadata for discovery, click-through to source +- Per-campaign federation toggle — admins choose what's shared +- Strict privacy boundary: **never** share emails, participant data, queue data, addresses, volunteer/canvass data, or credentials +- Hub admins curate their own directories — organic > control + +--- + +## Prisma Schema Changes + +**File:** `api/prisma/schema.prisma` + +### New enums +``` +FederationPeerStatus: PENDING | ACTIVE | REJECTED | SUSPENDED | OFFLINE +FederationRole: HUB | SPOKE +``` + +### New models + +**FederationIdentity** (singleton — this instance's federation profile) +- `enabled`, `hubEnabled`, `hubAutoApprove` +- Instance profile: `instanceName`, `instanceDescription`, `instanceUrl`, `instanceRegion`, `instanceTags` (Json), `instanceLogoUrl` +- Ed25519 keypair: `publicKey`, `privateKey` (encrypted at rest) +- Hub description, sync interval, last sync timestamp/error + +**FederationPeer** (one record per connection, in either direction) +- `role` (HUB or SPOKE), `remoteUrl` (unique per role+url) +- Remote instance profile fields (name, description, region, tags, logo, publicKey) +- Auth: `apiKey` (ours for them), `remoteApiKey` (theirs for us) — both encrypted +- Status tracking: `status`, `statusMessage`, `lastSeenAt`, `lastSyncAt`, `failureCount` +- Stats: `campaignsShared`, `responsesShared` +- Relation to `FederatedCampaign[]` + +**FederatedCampaign** (cached campaign metadata from peers) +- `peerId` → FederationPeer +- Remote identifiers: `remoteCampaignId`, `remoteCampaignSlug` +- Safe metadata: title, description, emailSubject (NOT body), callToAction, coverPhoto, status, targetGovernmentLevels, featureFlags (Json), createdByName +- Aggregate stats: `emailCount`, `responseCount` +- Source instance info (denormalized): `sourceInstanceName`, `sourceInstanceUrl`, `sourceInstanceRegion` +- Staleness tracking: `lastSyncedAt`, `isStale` +- Future adoption: `adoptedAsCampaignId` (nullable FK to local Campaign) +- Unique constraint: `[peerId, remoteCampaignId]` + +### Modifications to existing models + +**Campaign** — add `federated Boolean @default(false)` field + +**SiteSettings** — add `enableFederation Boolean @default(false)` feature toggle + +--- + +## API Module Structure + +**New directory:** `api/src/modules/federation/` + +| File | Purpose | +|------|---------| +| `federation.schemas.ts` | Zod schemas: identity update, peer registration, campaign sync, directory query, list filters | +| `federation.service.ts` | Core business logic: identity CRUD, peer management, `buildSafeCampaignPayload()`, campaign sync, directory serving | +| `federation-admin.routes.ts` | SUPER_ADMIN routes: identity management, peer approve/reject/suspend, manual sync trigger | +| `federation-peer.routes.ts` | Inter-instance routes: inbound registration, campaign sync, directory, heartbeat (API-key auth) | +| `federation-public.routes.ts` | Public browsing: federated campaigns list, instance directory (no auth) | +| `federation-crypto.service.ts` | Ed25519 keypair generation, request signing/verification | + +**New file:** `api/src/services/federation-sync-queue.service.ts` — BullMQ repeatable job for periodic sync + +### Route table + +**Admin routes** (`/api/federation/...`, SUPER_ADMIN + JWT auth): +| Method | Path | Description | +|--------|------|-------------| +| GET | `/identity` | Get federation config | +| PUT | `/identity` | Update config/profile | +| POST | `/identity/generate-keypair` | Generate Ed25519 keypair | +| GET | `/peers` | List all peers | +| POST | `/peers/register` | Register with a remote hub | +| POST | `/peers/:id/approve` | Approve incoming spoke | +| POST | `/peers/:id/reject` | Reject incoming spoke | +| POST | `/peers/:id/suspend` | Suspend peer | +| DELETE | `/peers/:id` | Remove peer | +| POST | `/sync` | Trigger manual sync | +| GET | `/sync/status` | Sync status + history | + +**Peer routes** (`/api/federation/peer/...`, API-key auth via `X-Federation-Key` header): +| Method | Path | Description | +|--------|------|-------------| +| POST | `/register` | Inbound spoke registration | +| POST | `/sync` | Inbound campaign metadata push | +| GET | `/directory` | Serve campaign directory | +| GET | `/profile` | Return instance profile | +| POST | `/heartbeat` | Liveness check | + +**Public routes** (`/api/federation/...`, no auth): +| Method | Path | Description | +|--------|------|-------------| +| GET | `/campaigns` | Browse federated campaigns (paginated, searchable) | +| GET | `/campaigns/:id` | Single federated campaign detail | +| GET | `/instances` | List known network instances | + +### Mounting in server.ts +``` +app.use('/api/federation', federationPublicRouter); // No auth — first +app.use('/api/federation', federationPeerRouter); // API-key auth +app.use('/api/federation', federationAdminRouter); // SUPER_ADMIN JWT +``` + +--- + +## Federation Protocol + +### Registration handshake +1. Spoke admin enters hub URL, clicks "Register" +2. Spoke sends `POST /api/federation/peer/register` to hub with instance profile + generated API key +3. Hub creates peer record (PENDING or ACTIVE if `hubAutoApprove`) +4. Hub responds with its own API key + peer ID +5. If approved (now or later), hub calls back to spoke's `/peer/register` to complete mutual registration +6. Both instances now have each other as peers (Spoke→HUB role, Hub→SPOKE role) + +### Campaign sync +- Spokes push federated campaigns to hubs on schedule (BullMQ repeatable job) +- Payload: array of safe campaign metadata + array of un-federated campaign IDs (for removal) +- Hub stores/updates `FederatedCampaign` records +- Sync includes heartbeat (updates `lastSeenAt`) + +### Privacy boundary enforcement +`buildSafeCampaignPayload()` in the service layer filters campaigns to only safe fields. **Never included:** emailBody, any email addresses, user IDs, participant data, moderation internals, custom recipients, calls data. + +### Offline handling +- Increment `failureCount` on sync failure; after 5 consecutive failures → status `OFFLINE` +- Mark federated campaigns as `isStale` after 24h offline +- Keep checking with exponential backoff (max 24h) +- Auto-recover when heartbeat succeeds + +--- + +## Security + +- **API-key auth:** `crypto.randomBytes(32).toString('hex')`, encrypted at rest with existing `encrypt()`/`decrypt()` utility +- **Custom middleware:** `authenticatePeer` checks `X-Federation-Key` header, verifies peer exists + is ACTIVE +- **Request signing (optional):** Ed25519 signatures on `X-Federation-Signature` header for non-repudiation (configurable, not enforced in MVP) +- **Rate limiting:** 30 req/min for peer routes, 60 req/min for public routes (separate Redis prefixes) +- **CORS:** Peer routes need permissive CORS (cross-domain by nature) +- **Input validation:** All incoming peer data Zod-validated + HTML-escaped before storage + +--- + +## Environment Variables + +Add to `api/src/config/env.ts`: +``` +ENABLE_FEDERATION: z.string().default('false') +FEDERATION_SYNC_INTERVAL_MINUTES: z.coerce.number().default(60) +FEDERATION_MAX_CAMPAIGNS_PER_SYNC: z.coerce.number().default(500) +FEDERATION_PEER_TIMEOUT_MS: z.coerce.number().default(15000) +FEDERATION_MAX_PEERS: z.coerce.number().default(50) +``` + +--- + +## Admin UI + +### FederationPage (`admin/src/pages/FederationPage.tsx`) + +4-tab page following PangolinPage pattern: + +**Tab 1 — Identity & Settings:** Toggle federation, instance profile form, keypair management, hub/spoke settings + +**Tab 2 — Connected Peers:** Table of peers (name, URL, role tag, status tag, campaigns shared, last sync, actions). "Register with Hub" button opens modal. Pending incoming registrations highlighted. + +**Tab 3 — Federated Campaigns:** Card grid/table of federated campaigns with search + filter (region, tags, government level). Click-through links to source instances. + +**Tab 4 — Sync Status:** Last/next sync, per-peer status, manual sync button, sync history. + +### Sidebar +Add to `buildMenuItems()` in `AppLayout.tsx`, gated on `settings?.enableFederation`: +```typescript +{ key: '/app/federation', icon: , label: 'Federation' } +``` +(Using `` since `` is already imported but used for Web submenu — may use `` or `` instead) + +### Route in App.tsx +```tsx +} /> +``` + +### Campaign form integration +Add `federated` checkbox to campaign create/edit form in CampaignsPage, visible only when `settings.enableFederation` is true. + +### TypeScript types +Add `FederationIdentity`, `FederationPeer`, `FederatedCampaign`, `FederationSyncStatus` interfaces to `admin/src/types/api.ts`. + +### Public network page (stretch goal in MVP) +`admin/src/pages/public/FederatedCampaignsPage.tsx` at `/network` route — card grid of federated campaigns with PublicLayout dark theme. + +--- + +## Prometheus Metrics + +Add to `api/src/utils/metrics.ts`: +- `cm_federation_peers_active` (Gauge) +- `cm_federation_campaigns_shared` (Gauge) +- `cm_federation_sync_duration_seconds` (Histogram) +- `cm_federation_sync_errors_total` (Counter with `peer_id` label) + +--- + +## Implementation Order + +| Step | Description | Files Created/Modified | Depends On | +|------|-------------|----------------------|------------| +| 1 | **Prisma schema** — Add enums, 3 new models, Campaign.federated, SiteSettings.enableFederation | `api/prisma/schema.prisma` | — | +| 2 | **Migration** — `npx prisma migrate dev --name add-federation` | `api/prisma/migrations/` | Step 1 | +| 3 | **Env vars** — Add federation config to env.ts + .env.example | `api/src/config/env.ts`, `.env.example` | — | +| 4 | **Crypto service** — Ed25519 keypair, sign/verify | `api/src/modules/federation/federation-crypto.service.ts` | — | +| 5 | **Schemas** — Zod validation for all federation endpoints | `api/src/modules/federation/federation.schemas.ts` | Step 1 | +| 6 | **Core service** — Identity CRUD, peer management, buildSafeCampaignPayload, campaign sync logic | `api/src/modules/federation/federation.service.ts` | Steps 2, 4, 5 | +| 7 | **Admin routes** — SUPER_ADMIN federation management | `api/src/modules/federation/federation-admin.routes.ts` | Step 6 | +| 8 | **Peer routes** — Inter-instance API with authenticatePeer middleware | `api/src/modules/federation/federation-peer.routes.ts` | Step 6 | +| 9 | **Public routes** — Browsing federated campaigns | `api/src/modules/federation/federation-public.routes.ts` | Step 6 | +| 10 | **Rate limiting** — Add federation rate limiters | `api/src/middleware/rate-limit.ts` | — | +| 11 | **Server mounting** — Import + mount routers, start sync queue | `api/src/server.ts` | Steps 7-10 | +| 12 | **Sync queue** — BullMQ repeatable job for periodic sync | `api/src/services/federation-sync-queue.service.ts` | Step 6 | +| 13 | **Metrics** — Prometheus counters/gauges | `api/src/utils/metrics.ts` | — | +| 14 | **Campaign form** — Add `federated` to schemas + service + CampaignsPage checkbox | `api/src/modules/influence/campaigns/campaigns.schemas.ts`, `campaigns.service.ts`, `admin/src/pages/CampaignsPage.tsx` | Step 2 | +| 15 | **Frontend types** — Federation TypeScript interfaces | `admin/src/types/api.ts` | — | +| 16 | **FederationPage** — 4-tab admin page | `admin/src/pages/FederationPage.tsx` | Steps 7, 15 | +| 17 | **Sidebar + routing** — Menu item + route in AppLayout/App.tsx | `admin/src/components/AppLayout.tsx`, `admin/src/App.tsx` | Step 16 | +| 18 | **Public network page** (stretch) — Federated campaigns browse | `admin/src/pages/public/FederatedCampaignsPage.tsx` | Steps 9, 15 | + +--- + +## Future Extensions (not in MVP, but models accommodate) + +- **Campaign adoption** — "Fork" a federated campaign locally (`FederatedCampaign.adoptedAsCampaignId`) +- **Cross-instance response sharing** — New `FederatedResponse` model synced alongside campaigns +- **Named networks/coalitions** — `FederationNetwork` + `FederationNetworkMember` models for named alliances +- **Hub-of-hubs discovery** — Hubs share known-hub lists for transitive discovery (gossip protocol) + +--- + +## Verification + +1. **Two-instance test:** Run two API instances on different ports, enable federation on both, register one with the other +2. **Campaign sync:** Create a federated campaign on spoke, verify it appears in hub's directory +3. **Privacy boundary:** Inspect sync payloads — verify no emails, user IDs, or email bodies leak +4. **Offline handling:** Stop one instance, verify the other marks it OFFLINE after 5 failed syncs, then recovers on restart +5. **Rate limiting:** Hit peer endpoints rapidly, verify 429 responses after threshold +6. **Feature gate:** Disable federation in settings, verify all routes return 403/hidden +7. **UI:** Verify sidebar item appears/hides with feature toggle, all 4 tabs functional diff --git a/admin/src/App.tsx b/admin/src/App.tsx index 1042cfd7..30a347a2 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -35,6 +35,9 @@ import GiteaPage from '@/pages/GiteaPage'; import MailHogPage from '@/pages/MailHogPage'; import MiniQRPage from '@/pages/MiniQRPage'; import ExcalidrawPage from '@/pages/ExcalidrawPage'; +import VaultwardenPage from '@/pages/VaultwardenPage'; +import RocketChatPage from '@/pages/RocketChatPage'; +import GancioPage from '@/pages/GancioPage'; import SettingsPage from '@/pages/SettingsPage'; import PangolinPage from '@/pages/PangolinPage'; import ObservabilityPage from '@/pages/ObservabilityPage'; @@ -67,6 +70,7 @@ import PlaylistViewerPage from '@/pages/public/PlaylistViewerPage'; import PlaylistManagementPage from '@/pages/media/PlaylistManagementPage'; import MyStatsPage from '@/pages/public/MyStatsPage'; import MySettingsPage from '@/pages/public/MySettingsPage'; +import VolunteerChatPage from '@/pages/volunteer/VolunteerChatPage'; import PricingPage from '@/pages/public/PricingPage'; import ShopPage from '@/pages/public/ShopPage'; import DonatePage from '@/pages/public/DonatePage'; @@ -235,6 +239,7 @@ export default function App() { } /> } /> } /> + } /> {/* Redirect old canvass routes to map with query param */} @@ -415,6 +420,30 @@ export default function App() { } /> + + + + } + /> + + + + } + /> + + + + } + /> , label: 'Tunnel' }, { key: '/app/observability', icon: , label: 'Monitoring' }, { key: '/app/services/nocodb', icon: , label: 'Database' }, + { key: '/app/services/vaultwarden', icon: , label: 'Vault' }, { key: '/app/services/mailhog', icon: , label: 'MailHog' }, ]}, { type: 'group', label: 'Tools', children: [ { key: '/app/services/n8n', icon: , label: 'Workflows' }, { key: '/app/services/gitea', icon: , label: 'Git' }, { key: '/app/services/excalidraw', icon: , label: 'Whiteboard' }, + ...(settings?.enableChat ? [{ key: '/app/services/rocketchat', icon: , label: 'Team Chat' }] : []), + { key: '/app/services/gancio', icon: , label: 'Events' }, { key: '/app/services/miniqr', icon: , label: 'QR Codes' }, ]}, ], diff --git a/admin/src/components/GrapesJSEditor.tsx b/admin/src/components/GrapesJSEditor.tsx index f4d20b39..bdc305d2 100644 --- a/admin/src/components/GrapesJSEditor.tsx +++ b/admin/src/components/GrapesJSEditor.tsx @@ -348,6 +348,33 @@ function generateBlockHtml(type: string, defaults: Record): str `; } + case 'gancio-events': { + const maxlength = defaults.maxlength || 10; + const evTheme = (defaults.theme as string) || 'dark'; + const tags = (defaults.tags as string) || ''; + const title = (defaults.title as string) || 'Upcoming Events'; + return ` +
+
+
+
+ + + +

${title}

+

Theme: ${evTheme} | Max: ${maxlength} events

+ ${tags ? `

Tags: ${tags}

` : ''} +

Events will render on published page

+
+
+
+
`; + } default: return `

Custom block: ${type}

`; } diff --git a/admin/src/components/PublicLayout.tsx b/admin/src/components/PublicLayout.tsx index 49956ba2..595e997b 100644 --- a/admin/src/components/PublicLayout.tsx +++ b/admin/src/components/PublicLayout.tsx @@ -109,6 +109,17 @@ export default function PublicLayout() { const footerText = settings?.footerText ?? 'Powered by Changemaker Lite'; const logoUrl = settings?.organizationLogoUrl; + // Resolve Gancio URL — subdomain in production, direct port in dev + const gancioUrl = (() => { + const host = window.location.hostname; + if (host !== 'localhost' && host.includes('.')) { + const protocol = window.location.protocol; + const baseDomain = host.split('.').slice(-2).join('.'); + return `${protocol}//events.${baseDomain}`; + } + return `http://localhost:8092`; + })(); + // Dynamic document title + favicon for public pages useEffect(() => { if (!settings) return; @@ -191,6 +202,9 @@ export default function PublicLayout() { } label="Shifts" active={activeRoute === '/shifts'} /> )} + {settings?.enableEvents !== false && ( + } label="Events" /> + )} {settings?.enableMediaFeatures !== false && ( } label="Gallery" active={activeRoute === '/gallery'} /> )} @@ -239,6 +253,12 @@ export default function PublicLayout() { Shifts )} + {settings?.enableEvents !== false && ( + <> + {' • '} + Events + + )} {settings?.enableMediaFeatures !== false && ( <> {' • '} @@ -310,6 +330,24 @@ export default function PublicLayout() { {item.label} ))} + {settings?.enableEvents !== false && ( + setDrawerOpen(false)} + style={{ + display: 'flex', alignItems: 'center', gap: 10, + padding: '12px 24px', + color: 'rgba(255,255,255,0.85)', + textDecoration: 'none', fontSize: 15, + borderRadius: 4, + }} + > + + Events + + )}
{isAuthenticated ? ( { + const items = [...BASE_NAV_ITEMS]; + if (settings?.enableChat) { + items.push({ key: '/volunteer/chat', icon: MessageOutlined, label: 'Chat' }); + } + return items; + }, [settings?.enableChat]); const activeKey = (() => { const path = location.pathname; diff --git a/admin/src/components/canvass/CutCampaignAnalyticsCard.tsx b/admin/src/components/canvass/CutCampaignAnalyticsCard.tsx new file mode 100644 index 00000000..1126545f --- /dev/null +++ b/admin/src/components/canvass/CutCampaignAnalyticsCard.tsx @@ -0,0 +1,123 @@ +import { useState, useEffect } from 'react'; +import { Card, Table, Progress, Tag, Button, Spin } from 'antd'; +import { ExportOutlined, FundOutlined } from '@ant-design/icons'; +import { api } from '@/lib/api'; +import type { CutCampaignAnalytics } from '@/types/api'; + +const SUPPORT_LABELS: Record = { + LEVEL_1: { label: 'Strong', color: '#27ae60' }, + LEVEL_2: { label: 'Likely', color: '#f1c40f' }, + LEVEL_3: { label: 'Unsure', color: '#e67e22' }, + LEVEL_4: { label: 'Oppose', color: '#e74c3c' }, +}; + +interface Props { + onExportCut?: (cutId: string) => void; +} + +export default function CutCampaignAnalyticsCard({ onExportCut }: Props) { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + api.get<{ cuts: CutCampaignAnalytics[] }>('/map/canvass/analytics/cuts') + .then(({ data: res }) => setData(res.cuts)) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + if (loading) { + return ( + Campaign Readiness} size="small"> +
+
+ ); + } + + if (data.length === 0) return null; + + return ( + Campaign Readiness by Cut} + size="small" + > + ( + = 80 ? '#52c41a' : r.completionPct >= 40 ? '#faad14' : '#ff4d4f'} + /> + ), + }, + { + title: 'Addresses', + dataIndex: 'totalAddresses', + key: 'total', + width: 80, + align: 'right' as const, + }, + { + title: 'With Email', + dataIndex: 'addressesWithEmail', + key: 'email', + width: 90, + align: 'right' as const, + render: (v: number) => ( + 0 ? '#52c41a' : '#999' }}>{v} + ), + }, + { + title: 'Support', + key: 'support', + width: 180, + render: (_, r) => ( +
+ {Object.entries(r.supportBreakdown).map(([level, count]) => { + const info = SUPPORT_LABELS[level]; + if (!info || count === 0) return null; + return ( + + {info.label}: {count} + + ); + })} +
+ ), + }, + ...(onExportCut ? [{ + title: '', + key: 'actions', + width: 60, + render: (_: unknown, r: CutCampaignAnalytics) => ( + , + , + , + ]} + > +
+ + ({ label: `${c.title} (${c.status})`, value: c.id }))} + /> + + + + {/* Preview Results */} + {previewing && ( +
+ +
+ )} + + {preview && !previewing && ( +
+ +
+ + + + 0 ? '#52c41a' : '#ff4d4f' }} + /> + + + + + + + {preview.byCut.length > 0 && ( + + {preview.byCut.map(c => ( + + {c.contacts} contacts ({c.withEmail} with email) + + ))} + + )} + + {preview.contactsWithEmail === 0 && ( + + )} + + )} + + ); +} diff --git a/admin/src/components/dashboard/ActivityFeedCard.tsx b/admin/src/components/dashboard/ActivityFeedCard.tsx new file mode 100644 index 00000000..43a093a4 --- /dev/null +++ b/admin/src/components/dashboard/ActivityFeedCard.tsx @@ -0,0 +1,145 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Card, Typography, Segmented, Button, Spin, Flex } from 'antd'; +import { + CalendarOutlined, + MailOutlined, + CompassOutlined, + UserAddOutlined, + MessageOutlined, + HistoryOutlined, +} from '@ant-design/icons'; +import dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; +import { api } from '@/lib/api'; +import type { ActivityFeedResult, ActivityItem } from '@/types/api'; + +dayjs.extend(relativeTime); + +const { Text } = Typography; + +const TYPE_CONFIG: Record = { + shift_signup: { color: '#eb2f96', icon: }, + response_submitted: { color: '#faad14', icon: }, + canvass_completed: { color: '#52c41a', icon: }, + email_sent: { color: '#1890ff', icon: }, + user_created: { color: '#722ed1', icon: }, +}; + +const MODULE_OPTIONS = [ + { label: 'All', value: 'all' }, + { label: 'Map', value: 'map' }, + { label: 'Influence', value: 'influence' }, + { label: 'Users', value: 'users' }, +]; + +function ActivityRow({ item }: { item: ActivityItem }) { + const config = TYPE_CONFIG[item.type]; + return ( + + {config.icon} + {item.title} + + {item.description} + + + {dayjs(item.timestamp).fromNow(true)} + + + ); +} + +export default function ActivityFeedCard() { + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [module, setModule] = useState('all'); + const [loading, setLoading] = useState(true); + + const fetchActivity = useCallback(async (p: number, mod: string, append: boolean) => { + setLoading(true); + try { + const res = await api.get('/dashboard/activity', { + params: { page: p, limit: 10, module: mod }, + }); + if (append) { + setItems(prev => [...prev, ...res.data.items]); + } else { + setItems(res.data.items); + } + setTotal(res.data.total); + } catch { + // non-critical + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + setPage(1); + fetchActivity(1, module, false); + }, [module, fetchActivity]); + + const handleLoadMore = () => { + const next = page + 1; + setPage(next); + fetchActivity(next, module, true); + }; + + const hasMore = items.length < total; + + return ( + Recent Activity} + size="small" + extra={ + setModule(val as string)} + options={MODULE_OPTIONS} + /> + } + styles={{ body: { padding: '4px 12px 6px' } }} + style={{ height: '100%' }} + > + {loading && items.length === 0 ? ( +
+ ) : items.length === 0 ? ( + No recent activity + ) : ( + <> +
+ {items.map(item => ( + + ))} +
+ {hasMore && ( +
+ +
+ )} + + )} +
+ ); +} diff --git a/admin/src/components/dashboard/ChatNotifierCard.tsx b/admin/src/components/dashboard/ChatNotifierCard.tsx new file mode 100644 index 00000000..fe599994 --- /dev/null +++ b/admin/src/components/dashboard/ChatNotifierCard.tsx @@ -0,0 +1,120 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Card, Typography, Spin, Tag, Flex, Button, Tooltip } from 'antd'; +import { + MessageOutlined, + ReloadOutlined, + RobotOutlined, + UserOutlined, +} from '@ant-design/icons'; +import dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; +import { api } from '@/lib/api'; +import type { ChatSummaryResult, ChatMessage } from '@/types/api'; + +dayjs.extend(relativeTime); + +const { Text } = Typography; + +const CHANNEL_COLORS: Record = { + general: 'default', + shifts: 'magenta', + canvassing: 'green', + campaigns: 'purple', +}; + +function ChatRow({ message }: { message: ChatMessage }) { + const cleanText = message.text + .replace(/\*\*(.*?)\*\*/g, '$1') + .replace(/\*(.*?)\*/g, '$1'); + + return ( + + + {message.isBot + ? + : + } + {message.username} + + #{message.channel} + + + {cleanText} + + + {dayjs(message.timestamp).fromNow(true)} + + + + ); +} + +export default function ChatNotifierCard() { + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(true); + + const fetchChat = useCallback(async () => { + setLoading(true); + try { + const res = await api.get('/dashboard/chat-summary'); + setResult(res.data); + } catch { + // non-critical widget + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchChat(); + const interval = setInterval(fetchChat, 2 * 60_000); + return () => clearInterval(interval); + }, [fetchChat]); + + if (result && !result.enabled) return null; + + return ( + Team Chat} + size="small" + extra={ +
+ + { + setExportPreselectedCuts([cutId]); + setExportOpen(true); + }} + /> ); @@ -338,6 +357,14 @@ export default function CanvassDashboardPage() { onSelectRoute={setHistoryRoute} volunteers={volunteers.map((v) => ({ userId: v.userId, name: v.name, email: v.email }))} /> + + {/* Export Contacts Modal */} + setExportOpen(false)} + cuts={cuts} + preselectedCutIds={exportPreselectedCuts} + /> ); } diff --git a/admin/src/pages/DashboardPage.tsx b/admin/src/pages/DashboardPage.tsx index 64fc38d5..4de5db48 100644 --- a/admin/src/pages/DashboardPage.tsx +++ b/admin/src/pages/DashboardPage.tsx @@ -34,7 +34,6 @@ import { CheckCircleFilled, CloseCircleFilled, MinusCircleFilled, - IdcardOutlined, HomeOutlined, } from '@ant-design/icons'; import { @@ -51,15 +50,19 @@ import RequestTrafficChart from '@/components/dashboard/RequestTrafficChart'; import LatencyBandsChart from '@/components/dashboard/LatencyBandsChart'; import ContainerPopover from '@/components/dashboard/ContainerPopover'; import ContainerMemoryChart from '@/components/dashboard/ContainerMemoryChart'; +import ActivityFeedCard from '@/components/dashboard/ActivityFeedCard'; +import TodayEventsCard from '@/components/dashboard/TodayEventsCard'; +import ChatNotifierCard from '@/components/dashboard/ChatNotifierCard'; import { buildServiceUrl } from '@/lib/service-url'; import type { DashboardSummary, QueueStats, ServicesStatus, ServicesConfig, SystemInfo, ContainerInfo, WeatherData, ApiMetrics, TimeSeriesResult, ContainerResource, ContainerResourcesResponse, + ConnectivityStatus, AppOutletContext, } from '@/types/api'; -const { Text, Title } = Typography; +const { Text } = Typography; // --- Pulse animation CSS (injected once) --- const PULSE_STYLE_ID = 'dashboard-pulse-css'; @@ -171,6 +174,7 @@ export default function DashboardPage() { const [apiMetrics, setApiMetrics] = useState(null); const [timeSeries, setTimeSeries] = useState(null); const [containerResources, setContainerResources] = useState(null); + const [connectivity, setConnectivity] = useState(null); const [loading, setLoading] = useState(true); const [lastRefresh, setLastRefresh] = useState(null); const [activeView, setActiveView] = useState<'dashboard' | 'homepage'>('dashboard'); @@ -208,6 +212,7 @@ export default function DashboardPage() { api.get('/dashboard/container-resources').then(({ data }) => { setContainerResources(data.containers || []); }).catch(() => {}), + api.get('/dashboard/connectivity').then(({ data }) => setConnectivity(data)).catch(() => {}), ); } await Promise.allSettled(promises); @@ -280,21 +285,20 @@ export default function DashboardPage() {
{/* === Welcome Banner === */} - - -
- - Welcome{user?.name ? `, ${user.name}` : ''} - - - {lastRefresh && `Updated ${lastRefresh.toLocaleTimeString()}`} - -
+ + + + Welcome{user?.name ? `, ${user.name}` : ''} + + + {lastRefresh && `Updated ${lastRefresh.toLocaleTimeString()}`} + {isSuperAdmin && homepageUrl && ( setActiveView(val as 'dashboard' | 'homepage')} options={[ @@ -306,35 +310,29 @@ export default function DashboardPage() { )} {activeView === 'dashboard' && ( - - {showInfluence && ( - - )} - {showMap && ( - - )} - {showMedia && ( - - )} - + + {showInfluence && - - - - - - - - + + +
- -
{getWeatherIcon(weather.weatherCode, weather.isDay)}
- {Math.round(weather.temperature)}{'°C'} -
- - {weather.weatherDescription} - -
- - {'Feels ' + Math.round(weather.apparentTemperature) + '° · ' + weather.humidity + '% · ' + Math.round(weather.windSpeed) + ' km/h'} - -
- - )} + {/* === Status Bar (weather + stats + pending actions + connectivity) === */} + {summary && ( + + + + {weather && ( + + {getWeatherIcon(weather.weatherCode, weather.isDay)} + {Math.round(weather.temperature)}°C + {weather.weatherDescription} + + )} + {/* Quick stat chips */} + } color="#1890ff" value={summary.users.total} label="Users" onClick={() => navigate('/app/users')} /> + {showInfluence && } color="#52c41a" value={summary.campaigns.active} label={`of ${summary.campaigns.total}`} onClick={() => navigate('/app/campaigns')} />} + {showMap && } color="#722ed1" value={summary.locations.total.toLocaleString()} label={`${geocodePct}% geo`} onClick={() => navigate('/app/map')} />} + {showInfluence && } color="#faad14" value={summary.emails.sent} label="sent" onClick={() => navigate('/app/email-queue')} />} + {showMedia && } color="#13c2c2" value={summary.videos.published} label={`of ${summary.videos.total}`} onClick={() => navigate('/app/media/library')} />} + {showMap && } color="#eb2f96" value={summary.shifts.upcoming} label={`${summary.shifts.open} open`} onClick={() => navigate('/app/map/shifts')} />} + {/* Pending action tags */} + {summary.responses.pending > 0 && ( + navigate('/app/responses')}> + {summary.responses.pending} pending + + )} + {summary.locations.total > 0 && summary.locations.total - summary.locations.geocoded > 0 && ( + navigate('/app/map')}> + {summary.locations.total - summary.locations.geocoded} ungeocoded + + )} + {summary.emails.queued > 0 && ( + navigate('/app/email-queue')}> + {summary.emails.queued} queued + + )} + {summary.campaignModeration.pendingReview > 0 && ( + navigate('/app/campaign-moderation')}> + {summary.campaignModeration.pendingReview} review + + )} + + {isSuperAdmin && connectivity && ( + + + + + + + )} + + + )} - - } color="#1890ff" onClick={() => navigate('/app/users')} /> - + {/* === Email Queue Widget (shown if queue has items) === */} + {queue && (queue.waiting > 0 || queue.active > 0 || queue.failed > 0) && ( + 0 ? '#ff4d4f' : queue.paused ? '#faad14' : '#1890ff'}` }} + styles={{ body: { padding: '6px 16px' } }} + > + + + +
+ Email Queue + {queue.paused && Paused} +
+ + Waiting} value={queue.waiting} valueStyle={{ fontSize: 18 }} /> + Active} value={queue.active} valueStyle={{ fontSize: 18, color: '#1890ff' }} /> + {queue.failed > 0 && ( + Failed} value={queue.failed} valueStyle={{ fontSize: 18, color: '#ff4d4f' }} /> + )} + +
+ +
+
+ )} + {/* === Module Overview Row (3 columns) === */} + {showInfluence && ( -
- } color="#52c41a" onClick={() => navigate('/app/campaigns')} /> - - )} - - {showMap && ( - - } color="#722ed1" onClick={() => navigate('/app/map')} /> - - )} - - {showInfluence && ( - - } color="#faad14" onClick={() => navigate('/app/email-queue')} /> - - )} - - {showMedia && ( - - } color="#13c2c2" onClick={() => navigate('/app/media/library')} /> - - )} - - {showMap && ( - - } color="#eb2f96" onClick={() => navigate('/app/map/shifts')} /> - - )} - - - {/* === Module Overview Row === */} - - {showInfluence && ( - + Influence} + title={ + + + Influence + {summary && {summary.campaigns.active} active / {summary.campaigns.total}} + + } size="small" extra={} style={{ height: '100%' }} > {summary && ( - -
- - - Campaigns: - {summary.campaigns.active} Active - {summary.campaigns.draft} Draft - {summary.campaigns.paused > 0 && {summary.campaigns.paused} Paused} + + + + {summary.campaigns.active} Active + {summary.campaigns.draft} Draft + {summary.campaigns.paused > 0 && {summary.campaigns.paused} Paused} + + + Responses: {summary.responses.total} + {summary.responses.pending > 0 && {summary.responses.pending} pending} + + + Emails: {summary.emails.sent} sent + {summary.emails.failed > 0 && {summary.emails.failed} failed} + + {queue && ( + + Queue: {queue.waiting} waiting + {queue.paused && Paused} -
- Responses: - {summary.responses.total} total - {summary.responses.pending > 0 && {summary.responses.pending} pending} -
-
- Queue: - {queue ? ( - <> - {queue.waiting} waiting, {queue.active} active - {queue.paused && Paused} - - ) : unavailable} -
- {summary.campaignModeration.pendingReview > 0 && ( -
- -
- )} -
-
- {/* Campaign status donut */} - {summary.campaigns.total > 0 && screens.sm && ( -
- + )} + + {summary.campaigns.total > 0 && screens.md && ( +
+
)} @@ -510,9 +531,15 @@ export default function DashboardPage() { )} {showMap && ( -
+ Map & Canvassing} + title={ + + + Map + {summary && {summary.locations.total.toLocaleString()} locations} + + } size="small" extra={} style={{ height: '100%' }} @@ -520,84 +547,90 @@ export default function DashboardPage() { {summary && ( - Geocoding: + Geocoded: {summary.locations.geocoded.toLocaleString()}/{summary.locations.total.toLocaleString()} -
- Addresses: - {summary.locations.addresses.toLocaleString()} - {summary.cuts.total} cuts -
-
- Canvassing: - {summary.canvass.totalVisits} visits - {summary.canvass.activeSessions > 0 && {summary.canvass.activeSessions} active} -
+ + Addresses: {summary.locations.addresses.toLocaleString()} + {summary.cuts.total} cuts + + + Canvassing: {summary.canvass.totalVisits} visits + {summary.canvass.activeSessions > 0 && {summary.canvass.activeSessions} active} + + + Shifts: {summary.shifts.upcoming} upcoming + {summary.shifts.open} open +
)}
)} - + Content} - size="small" - extra={} - style={{ height: '100%' }} - > - {summary && ( - -
- Pages: - {summary.pages.published} published - / {summary.pages.total} -
-
- Templates: - {summary.emailTemplates.total} -
- {showInfluence && ( -
- Rep Cache: - {summary.representatives.totalCached} -
- )} - {showMedia && ( -
- Videos: - {summary.videos.published} published / {summary.videos.total} -
- )} + title={ + + + Users & Content + {summary && {summary.users.total} users} - )} -
- - - - Users} + } size="small" extra={} style={{ height: '100%' }} > {summary && ( - - {Object.entries(summary.users.byRole) - .filter(([, count]) => count > 0) - .map(([role, count]) => ( - - {ROLE_LABELS[role] || role}: {count} - - ))} - {summary.users.suspended > 0 && Suspended: {summary.users.suspended}} - + + + {Object.entries(summary.users.byRole) + .filter(([, count]) => count > 0) + .map(([role, count]) => ( + + {ROLE_LABELS[role] || role}: {count} + + ))} + {summary.users.suspended > 0 && Suspended: {summary.users.suspended}} + + + Pages: {summary.pages.published} published + / {summary.pages.total} + + + Templates: {summary.emailTemplates.total} + {showInfluence && Reps: {summary.representatives.totalCached}} + + {showMedia && ( + + Videos: {summary.videos.published} published + / {summary.videos.total} + + )} + )} + {/* === Activity Feed + Events + Chat === */} + + + + + + + + + + + + + + + + {/* === System + Docker Section (SUPER_ADMIN only) === */} {isSuperAdmin && ( <> @@ -876,34 +909,6 @@ function MiniSystemChart({ timeSeries }: { timeSeries: TimeSeriesResult }) { ); } -// --- Stat Card Component --- - -function StatCard({ title, value, subtitle, icon, color, onClick }: { - title: string; - value?: number | null; - subtitle: string; - icon: React.ReactNode; - color: string; - onClick: () => void; -}) { - return ( - - {title}} - value={value ?? '--'} - prefix={icon} - valueStyle={{ color, fontSize: 22 }} - /> - {subtitle} - - ); -} // --- Service Badge Component (with pulse animation) --- @@ -927,6 +932,47 @@ const SERVICE_ICONS: Record = { homepage: , }; +// --- Quick Stat chip (for status bar) --- + +function QuickStat({ icon, color, value, label, onClick }: { + icon: React.ReactNode; + color: string; + value: string | number; + label: string; + onClick: () => void; +}) { + return ( + + {icon} + {value} + {label} + + ); +} + +function ConnectivityDot({ label, online }: { label: string; online: boolean }) { + return ( + + +
+ {label} + + + ); +} + function ServiceBadge({ name, online, icon }: { name: string; online?: boolean; diff --git a/admin/src/pages/GancioPage.tsx b/admin/src/pages/GancioPage.tsx new file mode 100644 index 00000000..8ca381b0 --- /dev/null +++ b/admin/src/pages/GancioPage.tsx @@ -0,0 +1,127 @@ +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useOutletContext } from 'react-router-dom'; +import { Button, Space, Badge, Spin, Grid, Result } from 'antd'; +import { ReloadOutlined, LinkOutlined, CalendarOutlined } from '@ant-design/icons'; +import { api } from '@/lib/api'; +import type { AppOutletContext } from '@/components/AppLayout'; +import type { ServicesStatus, ServicesConfig } from '@/types/api'; +import { buildServiceUrl } from '@/lib/service-url'; + +export default function GancioPage() { + const { setPageHeader } = useOutletContext(); + const screens = Grid.useBreakpoint(); + const isMobile = !screens.md; + + const [online, setOnline] = useState(null); + const [config, setConfig] = useState(null); + const [loading, setLoading] = useState(true); + + const fetchStatus = useCallback(async () => { + try { + const [statusRes, configRes] = await Promise.all([ + api.get('/services/status'), + api.get('/services/config'), + ]); + setOnline(statusRes.data.gancio.online); + setConfig(configRes.data); + } catch { + setOnline(false); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchStatus(); + }, [fetchStatus]); + + const serviceUrl = config + ? buildServiceUrl(config.gancioSubdomain, config.domain, config.gancioPort) + : null; + + const handleRefresh = useCallback(() => { + fetchStatus(); + }, [fetchStatus]); + + const headerActions = useMemo(() => ( + + + + {serviceUrl && ( + + )} + + ), [online, handleRefresh, serviceUrl]); + + useEffect(() => { + setPageHeader({ + title: 'Events', + actions: headerActions, + fullBleed: true + }); + return () => setPageHeader(null); + }, [setPageHeader, headerActions]); + + if (isMobile) { + return ( + } + /> + ); + } + + if (loading) { + return ( +
+ +
+ ); + } + + if (!online || !serviceUrl) { + return ( + + Retry + + } + /> + ); + } + + return ( +