From 9e51aac570f226653ddfb293ffe5ba3e69d08c1b Mon Sep 17 00:00:00 2001 From: bunker-admin Date: Thu, 26 Feb 2026 17:47:04 -0700 Subject: [PATCH] Okay Wish I could say I know exactly. Will do better next time promise lol --- .env.example | 48 + .mcp.json | 14 + CLAUDE.md | 17 + SOCIAL_SYSTEM_PLAN.md | 156 + admin/index.html | 9 +- admin/package-lock.json | 196 + admin/package.json | 4 + admin/src/App.tsx | 199 +- admin/src/components/AdBanner.tsx | 89 + admin/src/components/AppLayout.tsx | 405 +- admin/src/components/FeatureGate.tsx | 6 +- admin/src/components/GrapesJSEditor.tsx | 154 + admin/src/components/MediaPublicLayout.tsx | 5 +- admin/src/components/PublicLayout.tsx | 385 +- admin/src/components/PublicNavBar.tsx | 511 +++ admin/src/components/PublicSearchModal.tsx | 208 + admin/src/components/VolunteerFooterNav.tsx | 10 +- admin/src/components/VolunteerLayout.tsx | 9 +- .../calendar/EventSubmissionForm.tsx | 236 + .../components/calendar/UnifiedCalendar.tsx | 431 ++ admin/src/components/chat/ChatPanel.tsx | 210 + admin/src/components/chat/ChatPanelTray.tsx | 115 + admin/src/components/chat/ChatWidgetFAB.tsx | 170 + .../src/components/chat/RocketChatWidget.tsx | 26 + .../command-palette/CommandPalette.tsx | 510 +++ .../components/command-palette/registry.ts | 773 ++++ admin/src/components/command-palette/types.ts | 33 + .../command-palette/useCommandIndex.ts | 81 + .../command-palette/useEntitySearch.ts | 216 + .../components/dashboard/ActivityFeedCard.tsx | 4 +- .../components/dashboard/ChatNotifierCard.tsx | 57 +- .../dashboard/GiteaActivityCard.tsx | 95 + .../dashboard/NewsletterStatsCard.tsx | 59 +- .../dashboard/RocketChatStatsCard.tsx | 96 + .../dashboard/UpcomingMeetingsCard.tsx | 149 + .../dashboard/UpcomingShiftsCard.tsx | 4 +- .../dashboard/VaultwardenAdoptionCard.tsx | 95 + admin/src/components/map/EventLayerToggle.tsx | 57 + admin/src/components/map/EventMarkers.tsx | 116 + admin/src/components/media/AdPickerModal.tsx | 136 + admin/src/components/media/MediaSidebar.tsx | 7 +- .../src/components/media/PhotoInsertModal.tsx | 449 ++ .../src/components/media/PhotoPickerModal.tsx | 399 ++ .../src/components/people/ConnectionForm.tsx | 176 + .../src/components/people/ConnectionGraph.tsx | 354 ++ .../components/people/ContactAddressPanel.tsx | 243 ++ .../components/people/ContactEmailPanel.tsx | 248 ++ .../components/people/ContactPhonePanel.tsx | 248 ++ .../people/CreateUserFromContactModal.tsx | 139 + .../people/DuplicateDetectionDrawer.tsx | 218 + .../people/EngagementSummaryPanel.tsx | 189 + .../src/components/people/HouseholdPanel.tsx | 163 + .../components/people/MergeContactModal.tsx | 306 ++ .../people/PersonActivityTimeline.tsx | 182 + admin/src/components/people/PersonCard.tsx | 152 + .../components/people/PersonDetailDrawer.tsx | 903 ++++ .../components/people/ProfileLinkActions.tsx | 504 +++ .../components/people/ProfilePreviewModal.tsx | 183 + .../components/people/TagManagementModal.tsx | 443 ++ .../people/UserAccountStatusPanel.tsx | 82 + .../src/components/people/VideoCallModal.tsx | 134 + admin/src/components/public/ActivityFeed.tsx | 111 + .../src/components/public/HomepageSection.tsx | 31 + .../components/public/NewsletterSignup.tsx | 68 + .../src/components/public/RelatedContent.tsx | 115 + .../components/public/ShiftSignupModal.tsx | 215 + admin/src/components/social/FeedCard.tsx | 98 + admin/src/components/social/FriendButton.tsx | 142 + .../components/social/FriendSuggestions.tsx | 117 + .../social/FriendsAttendingBadge.tsx | 62 + .../social/FriendsCampaignBadge.tsx | 62 + admin/src/components/social/FriendsOnMap.tsx | 121 + admin/src/components/social/GroupCard.tsx | 55 + admin/src/components/social/MessageButton.tsx | 55 + .../components/social/NotificationBell.tsx | 138 + .../src/components/social/OnlineIndicator.tsx | 29 + admin/src/components/social/PokeButton.tsx | 73 + .../components/social/RecommendVideoModal.tsx | 169 + .../components/social/SocialNetworkGraph.tsx | 283 ++ .../src/components/social/SocialUserNode.tsx | 86 + admin/src/components/social/UserAvatar.tsx | 64 + admin/src/hooks/useDocumentTitle.ts | 14 + admin/src/hooks/usePageAds.ts | 27 + admin/src/hooks/usePostalCode.ts | 21 + admin/src/hooks/useSSE.ts | 126 + admin/src/lib/service-url.ts | 43 +- admin/src/pages/CampaignsPage.tsx | 157 +- admin/src/pages/CutsPage.tsx | 248 +- admin/src/pages/DashboardPage.tsx | 90 +- admin/src/pages/DataQualityDashboardPage.tsx | 30 +- admin/src/pages/DocsCommentsPage.tsx | 441 ++ admin/src/pages/DocsPage.tsx | 287 +- admin/src/pages/EmailQueuePage.tsx | 80 +- admin/src/pages/JitsiAuthPage.tsx | 90 + admin/src/pages/JitsiMeetPage.tsx | 292 ++ admin/src/pages/LandingPagesPage.tsx | 238 +- admin/src/pages/LocationsPage.tsx | 190 +- admin/src/pages/MapSettingsPage.tsx | 77 +- admin/src/pages/MkDocsSettingsPage.tsx | 462 +- admin/src/pages/NavigationSettingsPage.tsx | 319 ++ admin/src/pages/ObservabilityPage.tsx | 70 +- admin/src/pages/PangolinPage.tsx | 46 +- admin/src/pages/PeoplePage.tsx | 584 +++ admin/src/pages/RepresentativesPage.tsx | 211 +- admin/src/pages/ResponsesPage.tsx | 17 +- admin/src/pages/SettingsPage.tsx | 287 +- admin/src/pages/ShiftsPage.tsx | 159 +- admin/src/pages/UsersPage.tsx | 480 ++- admin/src/pages/WalkSheetPage.tsx | 3 +- .../pages/media/AdAnalyticsDashboardPage.tsx | 322 ++ admin/src/pages/media/GalleryAdsPage.tsx | 114 +- .../src/pages/payments/DonationPagesPage.tsx | 374 ++ admin/src/pages/payments/DonationsPage.tsx | 43 +- .../pages/payments/PaymentSettingsPage.tsx | 206 +- admin/src/pages/payments/PlansPage.tsx | 404 ++ admin/src/pages/payments/ProductsPage.tsx | 350 +- admin/src/pages/public/CampaignPage.tsx | 34 +- admin/src/pages/public/CampaignsListPage.tsx | 32 +- admin/src/pages/public/ContactProfilePage.tsx | 827 ++++ admin/src/pages/public/DonatePage.tsx | 157 +- .../pages/public/DonationPagesListPage.tsx | 214 + admin/src/pages/public/EventsPage.tsx | 142 + admin/src/pages/public/HomePage.tsx | 344 ++ admin/src/pages/public/LandingPage.tsx | 74 + admin/src/pages/public/MapPage.tsx | 124 +- admin/src/pages/public/MediaGalleryPage.tsx | 2 +- admin/src/pages/public/MediaViewerPage.tsx | 13 +- admin/src/pages/public/MeetingJoinPage.tsx | 132 + admin/src/pages/public/PagesIndexPage.tsx | 127 + admin/src/pages/public/PlanDetailPage.tsx | 287 ++ admin/src/pages/public/PlaylistViewerPage.tsx | 11 +- admin/src/pages/public/PricingPage.tsx | 20 +- admin/src/pages/public/ProductDetailPage.tsx | 293 ++ admin/src/pages/public/ShiftsPage.tsx | 208 +- admin/src/pages/public/ShopPage.tsx | 159 +- admin/src/pages/sms/SmsCampaignsPage.tsx | 234 + admin/src/pages/sms/SmsContactsPage.tsx | 262 ++ admin/src/pages/sms/SmsConversationsPage.tsx | 242 ++ admin/src/pages/sms/SmsDashboardPage.tsx | 179 + admin/src/pages/sms/SmsSetupPage.tsx | 674 +++ .../src/pages/social/SocialDashboardPage.tsx | 288 ++ admin/src/pages/social/SocialGraphPage.tsx | 259 ++ .../src/pages/social/SocialModerationPage.tsx | 441 ++ .../src/pages/volunteer/AchievementsPage.tsx | 206 + admin/src/pages/volunteer/DiscoverPage.tsx | 150 + admin/src/pages/volunteer/FriendsPage.tsx | 262 ++ admin/src/pages/volunteer/GroupDetailPage.tsx | 186 + .../src/pages/volunteer/NotificationsPage.tsx | 194 + admin/src/pages/volunteer/SocialFeedPage.tsx | 135 + .../src/pages/volunteer/SocialProfilePage.tsx | 193 + .../src/pages/volunteer/VolunteerMapPage.tsx | 4 + .../pages/volunteer/VolunteerShiftsPage.tsx | 39 +- admin/src/stores/chat-widget.store.ts | 164 + admin/src/stores/command-palette.store.ts | 38 + admin/src/stores/favorites.store.ts | 47 + admin/src/stores/social.store.ts | 175 + admin/src/types/api.ts | 636 +++ admin/src/types/gallery-ads.ts | 24 + admin/src/types/sms.ts | 162 + admin/src/types/social.ts | 226 + admin/src/utils/badges.ts | 33 + admin/src/utils/menu-items.ts | 64 + admin/src/utils/photoCardHtml.ts | 151 + admin/src/vite-env.d.ts | 12 + admin/tsconfig.tsbuildinfo | 2 +- api/prisma/init-gitea-db.sh | 22 + .../migration.sql | 3823 +++++++++++++++++ .../migration.sql | 2 + .../migration.sql | 14 + .../migration.sql | 5 + .../migration.sql | 2 + .../migration.sql | 3 + .../migration.sql | 38 + .../migration.sql | 2 + .../migration.sql | 25 + .../migration.sql | 20 + api/prisma/schema.prisma | 644 ++- api/prisma/seed.ts | 140 +- api/src/config/env.ts | 27 + api/src/middleware/rate-limit.ts | 136 + .../activity/activity-public.routes.ts | 17 + .../activity/activity-public.service.ts | 126 + api/src/modules/auth/auth.routes.ts | 57 + api/src/modules/auth/auth.service.ts | 49 + api/src/modules/dashboard/dashboard.routes.ts | 44 + .../modules/dashboard/dashboard.service.ts | 227 + .../docs-comments/docs-comments.routes.ts | 266 ++ .../docs-comments/docs-comments.schemas.ts | 35 + .../docs-comments/docs-comments.service.ts | 331 ++ api/src/modules/docs/docs-files.service.ts | 36 + api/src/modules/docs/docs.routes.ts | 20 + .../modules/docs/header-builder.schemas.ts | 2 +- .../modules/docs/header-builder.service.ts | 364 +- api/src/modules/docs/mkdocs-config.service.ts | 123 +- .../modules/events/events-public.routes.ts | 224 + .../events/unified-calendar.service.ts | 179 + .../gallery-ads/gallery-ads-admin.routes.ts | 15 + .../gallery-ads/gallery-ads-public.routes.ts | 6 +- .../gallery-ads/gallery-ads.schemas.ts | 17 + .../gallery-ads/gallery-ads.service.ts | 133 + api/src/modules/homepage/homepage.routes.ts | 16 + api/src/modules/homepage/homepage.service.ts | 243 ++ .../campaign-emails.service.ts | 15 + .../campaigns/campaigns-public.routes.ts | 96 + api/src/modules/jitsi/jitsi.routes.ts | 332 ++ api/src/modules/jitsi/jitsi.utils.ts | 30 + .../modules/map/canvass/canvass.service.ts | 19 +- api/src/modules/map/events/events.routes.ts | 14 + api/src/modules/map/events/events.service.ts | 176 + .../map/locations/locations.service.ts | 20 +- .../modules/map/settings/settings.schemas.ts | 6 + api/src/modules/map/shifts/shifts.routes.ts | 73 + api/src/modules/map/shifts/shifts.service.ts | 168 + api/src/modules/media/routes/photos.routes.ts | 58 +- .../newsletter/newsletter-public.routes.ts | 77 + api/src/modules/og/og.routes.ts | 170 + api/src/modules/pages/pages-public.routes.ts | 24 + .../payments/donation-pages-admin.routes.ts | 92 + .../payments/donation-pages-public.routes.ts | 67 + .../payments/donation-pages.schemas.ts | 39 + .../payments/donation-pages.service.ts | 283 ++ api/src/modules/payments/donations.service.ts | 39 +- .../modules/payments/payments-admin.routes.ts | 36 +- .../payments/payments-public.routes.ts | 29 +- api/src/modules/payments/payments.schemas.ts | 16 + api/src/modules/payments/plans.service.ts | 186 + api/src/modules/payments/products.service.ts | 66 +- api/src/modules/payments/webhook.service.ts | 17 +- api/src/modules/people/people.routes.ts | 753 ++++ api/src/modules/people/people.schemas.ts | 147 + api/src/modules/people/people.service.ts | 2541 +++++++++++ .../modules/people/profile-public.routes.ts | 254 ++ .../modules/people/profile-public.schemas.ts | 33 + api/src/modules/people/profile.service.ts | 690 +++ api/src/modules/people/tags.schemas.ts | 33 + api/src/modules/people/tags.service.ts | 452 ++ .../modules/rocketchat/rocketchat.service.ts | 123 +- api/src/modules/search/search.routes.ts | 37 + api/src/modules/search/search.service.ts | 158 + api/src/modules/services/services.routes.ts | 12 +- api/src/modules/settings/settings.routes.ts | 47 + api/src/modules/settings/settings.schemas.ts | 49 + api/src/modules/settings/settings.service.ts | 4 +- .../sms/campaigns/sms-campaigns.routes.ts | 108 + .../sms/campaigns/sms-campaigns.schemas.ts | 18 + .../sms/campaigns/sms-campaigns.service.ts | 172 + .../sms/contacts/sms-contacts.routes.ts | 109 + .../sms/contacts/sms-contacts.schemas.ts | 20 + .../sms/contacts/sms-contacts.service.ts | 294 ++ .../conversations/sms-conversations.routes.ts | 83 + .../sms-conversations.service.ts | 139 + .../modules/sms/device/sms-device.routes.ts | 43 + .../modules/sms/device/sms-device.service.ts | 55 + .../sms/messages/sms-messages.routes.ts | 46 + .../sms/messages/sms-messages.service.ts | 70 + api/src/modules/sms/setup/sms-setup.routes.ts | 219 + api/src/modules/social/achievements.routes.ts | 99 + .../modules/social/achievements.service.ts | 391 ++ api/src/modules/social/block.routes.ts | 49 + api/src/modules/social/block.service.ts | 97 + api/src/modules/social/feed.routes.ts | 34 + api/src/modules/social/feed.service.ts | 238 + api/src/modules/social/friendship.routes.ts | 133 + api/src/modules/social/friendship.service.ts | 371 ++ api/src/modules/social/group.routes.ts | 87 + api/src/modules/social/group.service.ts | 310 ++ api/src/modules/social/integration.routes.ts | 45 + api/src/modules/social/integration.service.ts | 110 + api/src/modules/social/messaging.service.ts | 72 + api/src/modules/social/notification.routes.ts | 94 + .../modules/social/notification.service.ts | 145 + api/src/modules/social/poke.routes.ts | 78 + api/src/modules/social/poke.service.ts | 122 + api/src/modules/social/presence.service.ts | 131 + api/src/modules/social/privacy.routes.ts | 33 + api/src/modules/social/privacy.service.ts | 36 + api/src/modules/social/profile.routes.ts | 136 + .../modules/social/recommendation.routes.ts | 90 + .../modules/social/recommendation.service.ts | 138 + api/src/modules/social/social-admin.routes.ts | 103 + .../modules/social/social-admin.service.ts | 619 +++ api/src/modules/social/social.middleware.ts | 16 + api/src/modules/social/social.rate-limits.ts | 39 + api/src/modules/social/social.routes.ts | 51 + api/src/modules/social/social.schemas.ts | 50 + api/src/modules/social/sse.routes.ts | 59 + api/src/modules/social/sse.service.ts | 133 + api/src/modules/social/suggestions.routes.ts | 33 + api/src/modules/social/suggestions.service.ts | 345 ++ api/src/modules/users/provisioning.routes.ts | 91 + api/src/modules/users/users.routes.ts | 29 + api/src/modules/users/users.service.ts | 47 + api/src/server.ts | 117 + api/src/services/docker.service.ts | 26 +- api/src/services/email.service.ts | 108 + .../services/gancio-settings-sync.service.ts | 437 ++ api/src/services/gancio.client.ts | 80 +- api/src/services/gitea.client.ts | 604 +++ .../services/listmonk-event-sync.service.ts | 149 +- api/src/services/listmonk-sync.service.ts | 67 +- api/src/services/listmonk.client.ts | 66 + .../services/notification-queue.service.ts | 69 +- api/src/services/reengagement.service.ts | 197 + api/src/services/rocketchat.client.ts | 75 + .../services/sms-device-monitor.service.ts | 68 + api/src/services/sms-queue.service.ts | 245 ++ api/src/services/sms-response-sync.service.ts | 207 + api/src/services/social-digest.service.ts | 211 + api/src/services/tailscale.client.ts | 127 + api/src/services/termux.client.ts | 281 ++ .../user-provisioning/gitea.provisioner.ts | 152 + api/src/services/user-provisioning/index.ts | 24 + .../user-provisioning/listmonk.provisioner.ts | 109 + .../provisioner.interface.ts | 99 + .../user-provisioning/provisioning.service.ts | 491 +++ .../rocketchat.provisioner.ts | 130 + .../vaultwarden.provisioner.ts | 87 + api/src/services/vaultwarden.client.ts | 170 + api/src/templates/email/profile-link.html | 100 + api/src/templates/email/profile-link.txt | 13 + api/src/templates/email/social-digest.html | 155 + api/src/templates/email/social-digest.txt | 19 + .../email/volunteer-reengagement.html | 107 + .../email/volunteer-reengagement.txt | 13 + .../email/volunteer-shift-thank-you.html | 113 + .../email/volunteer-shift-thank-you.txt | 15 + api/src/utils/escapeHtml.ts | 9 + api/src/utils/slug.ts | 12 + .../components/DiscoverInstancesDrawer.tsx | 6 +- .../src/components/ServiceHealthGrid.tsx | 30 +- .../admin/src/pages/CreateWizardPage.tsx | 252 +- .../admin/src/pages/InstanceDetailPage.tsx | 263 +- .../admin/src/pages/RegisterInstancePage.tsx | 24 + .../admin/src/types/api.ts | 10 + .../migration.sql | 2 + .../migration.sql | 2 + .../migration.sql | 6 + .../api/prisma/schema.prisma | 11 +- .../api/src/modules/backups/backup.routes.ts | 2 +- .../src/modules/instances/instances.routes.ts | 44 +- .../modules/instances/instances.schemas.ts | 42 +- .../modules/instances/instances.service.ts | 176 +- .../api/src/modules/instances/provisioner.ts | 11 +- .../src/modules/settings/settings.routes.ts | 2 +- changemaker-control-panel/api/src/server.ts | 10 + .../api/src/services/backup.service.ts | 27 +- .../api/src/services/discovery.service.ts | 12 + .../api/src/services/docker.service.ts | 2 +- .../api/src/services/port-allocator.ts | 14 +- .../api/src/services/secret-generator.ts | 17 +- .../api/src/services/template-engine.ts | 46 + .../configs/pangolin/resources.yml.hbs | 91 +- .../templates/docker-compose.yml.hbs | 410 +- changemaker-control-panel/templates/env.hbs | 49 +- .../templates/nginx/conf.d/default.conf.hbs | 389 +- .../templates/nginx/conf.d/services.conf.hbs | 35 + config.sh | 118 +- .../{ => dashboards}/api-performance.json | 0 .../application-overview.json | 2 +- .../grafana/{ => dashboards}/dashboards.yml | 0 .../{ => dashboards}/system-health.json | 0 .../grafana/{ => datasources}/datasources.yml | 0 configs/pangolin/resources.yml | 6 + docker-compose.yml | 185 +- mcp-server/package-lock.json | 1716 ++++++++ mcp-server/package.json | 26 + mcp-server/src/api-client.ts | 126 + mcp-server/src/index.ts | 82 + mcp-server/src/server.ts | 83 + mcp-server/src/tool-registry.ts | 172 + mcp-server/src/tools/campaigns.ts | 88 + .../src/tools/composite/daily-briefing.ts | 56 + .../src/tools/composite/outreach-report.ts | 65 + .../src/tools/composite/volunteer-summary.ts | 78 + mcp-server/src/tools/dashboard.ts | 39 + mcp-server/src/tools/locations.ts | 55 + mcp-server/src/tools/meta.ts | 46 + mcp-server/src/tools/packs/admin.ts | 130 + mcp-server/src/tools/packs/email.ts | 118 + mcp-server/src/tools/packs/media.ts | 89 + mcp-server/src/tools/packs/payments.ts | 131 + mcp-server/src/tools/packs/sms.ts | 95 + mcp-server/src/tools/people.ts | 50 + mcp-server/src/tools/settings.ts | 26 + mcp-server/src/tools/shifts.ts | 74 + mcp-server/tsconfig.json | 20 + .../images/social/comments/callback.png | Bin 0 -> 64477 bytes .../social/docs/admin/advocacy/campaigns.png | Bin 0 -> 81434 bytes .../docs/admin/advocacy/email-queue.png | Bin 0 -> 65369 bytes .../social/docs/admin/advocacy/index.png | Bin 0 -> 68951 bytes .../docs/admin/advocacy/representatives.png | Bin 0 -> 70329 bytes .../social/docs/admin/advocacy/responses.png | Bin 0 -> 76661 bytes .../docs/admin/broadcast/email-templates.png | Bin 0 -> 68252 bytes .../social/docs/admin/broadcast/index.png | Bin 0 -> 70530 bytes .../docs/admin/broadcast/newsletter.png | Bin 0 -> 74906 bytes .../social/docs/admin/broadcast/sms.png | Bin 0 -> 75860 bytes .../images/social/docs/admin/dashboard.png | Bin 0 -> 70885 bytes .../assets/images/social/docs/admin/index.png | Bin 68526 -> 70130 bytes .../images/social/docs/admin/map/areas.png | Bin 0 -> 72831 bytes .../social/docs/admin/map/canvassing.png | Bin 0 -> 70588 bytes .../social/docs/admin/map/data-quality.png | Bin 0 -> 69165 bytes .../images/social/docs/admin/map/index.png | Bin 0 -> 74009 bytes .../social/docs/admin/map/locations.png | Bin 0 -> 66758 bytes .../images/social/docs/admin/map/settings.png | Bin 0 -> 74393 bytes .../images/social/docs/admin/map/shifts.png | Bin 0 -> 67649 bytes .../images/social/docs/admin/media/ads.png | Bin 0 -> 71486 bytes .../social/docs/admin/media/analytics.png | Bin 0 -> 70053 bytes .../social/docs/admin/media/curated.png | Bin 0 -> 71637 bytes .../images/social/docs/admin/media/index.png | Bin 0 -> 66646 bytes .../social/docs/admin/media/library.png | Bin 0 -> 68261 bytes .../social/docs/admin/media/moderation.png | Bin 0 -> 68917 bytes .../social/docs/admin/payments/donations.png | Bin 0 -> 68503 bytes .../social/docs/admin/payments/index.png | Bin 0 -> 69232 bytes .../social/docs/admin/payments/plans.png | Bin 0 -> 63964 bytes .../social/docs/admin/payments/products.png | Bin 0 -> 68892 bytes .../social/docs/admin/payments/settings.png | Bin 0 -> 75544 bytes .../social/docs/admin/people-access.png | Bin 0 -> 69536 bytes .../social/docs/admin/services/index.png | Bin 0 -> 65862 bytes .../docs/admin/services/integrations.png | Bin 0 -> 72809 bytes .../social/docs/admin/services/monitoring.png | Bin 0 -> 68232 bytes .../social/docs/admin/services/tunnel.png | Bin 0 -> 71164 bytes .../docs/admin/services/user-provisioning.png | Bin 0 -> 68349 bytes .../images/social/docs/admin/settings.png | Bin 0 -> 75069 bytes .../social/docs/admin/web/documentation.png | Bin 0 -> 70880 bytes .../images/social/docs/admin/web/homepage.png | Bin 0 -> 74214 bytes .../images/social/docs/admin/web/index.png | Bin 0 -> 69891 bytes .../social/docs/admin/web/landing-pages.png | Bin 0 -> 68641 bytes .../social/docs/admin/web/navigation.png | Bin 0 -> 71566 bytes .../social/docs/features/achievements.png | Bin 0 -> 81026 bytes .../social/docs/features/automation.png | Bin 0 -> 80274 bytes .../images/social/docs/features/campaigns.png | Bin 0 -> 81434 bytes .../images/social/docs/features/chat.png | Bin 0 -> 79306 bytes .../social/docs/features/command-palette.png | Bin 0 -> 68127 bytes .../social/docs/features/contact-profile.png | Bin 0 -> 80320 bytes .../social/docs/features/data-quality.png | Bin 0 -> 81118 bytes .../social/docs/features/docs-analytics.png | Bin 0 -> 80418 bytes .../social/docs/features/docs-comments.png | Bin 0 -> 74225 bytes .../social/docs/features/email-templates.png | Bin 0 -> 68252 bytes .../images/social/docs/features/events.png | Bin 0 -> 75084 bytes .../social/docs/features/gallery-ads.png | Bin 0 -> 72616 bytes .../images/social/docs/features/homepage.png | Bin 0 -> 74214 bytes .../social/docs/features/landing-pages.png | Bin 0 -> 68641 bytes .../social/docs/features/map-canvassing.png | Bin 0 -> 74564 bytes .../social/docs/features/media-manager.png | Bin 0 -> 72385 bytes .../social/docs/features/navigation.png | Bin 0 -> 71566 bytes .../social/docs/features/newsletter.png | Bin 0 -> 74906 bytes .../social/docs/features/og-sharing.png | Bin 0 -> 84066 bytes .../social/docs/features/password-manager.png | Bin 0 -> 85948 bytes .../images/social/docs/features/payments.png | Bin 0 -> 77122 bytes .../images/social/docs/features/people.png | Bin 0 -> 73930 bytes .../social/docs/features/quick-join.png | Bin 0 -> 77756 bytes .../images/social/docs/features/settings.png | Bin 0 -> 75069 bytes .../images/social/docs/features/sms.png | Bin 0 -> 75860 bytes .../images/social/docs/features/social.png | Bin 0 -> 74875 bytes .../docs/features/user-provisioning.png | Bin 0 -> 73261 bytes .../docs/features/video-conferencing.png | Bin 0 -> 81103 bytes .../social/docs/features/whiteboard.png | Bin 0 -> 82281 bytes .../social/docs/getting-started/features.png | Bin 0 -> 68519 bytes .../docs/getting-started/first-steps.png | Bin 0 -> 68355 bytes .../docs/getting-started/installation.png | Bin 0 -> 64260 bytes .../social/assets/images/social/docs/phil.png | Bin 63043 -> 69887 bytes .../social/docs/user-guide/campaigns.png | Bin 0 -> 70610 bytes .../social/docs/user-guide/donations.png | Bin 0 -> 68827 bytes .../images/social/docs/user-guide/events.png | Bin 0 -> 75084 bytes .../images/social/docs/user-guide/gallery.png | Bin 0 -> 68035 bytes .../images/social/docs/user-guide/index.png | Bin 0 -> 66779 bytes .../images/social/docs/user-guide/map.png | Bin 0 -> 65131 bytes .../images/social/docs/user-guide/profile.png | Bin 0 -> 80320 bytes .../images/social/docs/user-guide/shifts.png | Bin 0 -> 68456 bytes .../images/social/docs/user-guide/shop.png | Bin 0 -> 70160 bytes .../social/docs/volunteer/achievements.png | Bin 0 -> 81026 bytes .../social/docs/volunteer/canvassing.png | Bin 0 -> 72312 bytes .../images/social/docs/volunteer/index.png | Bin 69119 -> 70735 bytes .../images/social/docs/volunteer/shifts.png | Bin 0 -> 69026 bytes .../images/social/docs/volunteer/social.png | Bin 0 -> 75071 bytes mkdocs/.cache/plugin/social/manifest.json | 92 +- mkdocs/docs/assets/css/image-gallery.css | 437 ++ mkdocs/docs/assets/js/image-gallery.js | 611 +++ .../repo-data/admin-changemaker.lite.json | 4 +- .../repo-data/anthropics-claude-code.json | 10 +- .../assets/repo-data/coder-code-server.json | 10 +- .../repo-data/gethomepage-homepage.json | 8 +- .../docs/assets/repo-data/go-gitea-gitea.json | 10 +- .../docs/assets/repo-data/knadh-listmonk.json | 8 +- .../docs/assets/repo-data/lyqht-mini-qr.json | 8 +- 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 | 10 +- .../repo-data/squidfunk-mkdocs-material.json | 8 +- mkdocs/docs/comments/callback.md | 26 + mkdocs/docs/docs/admin/advocacy/campaigns.md | 90 + .../docs/docs/admin/advocacy/email-queue.md | 25 + mkdocs/docs/docs/admin/advocacy/index.md | 18 + .../docs/admin/advocacy/representatives.md | 24 + mkdocs/docs/docs/admin/advocacy/responses.md | 25 + .../docs/admin/broadcast/email-templates.md | 67 + mkdocs/docs/docs/admin/broadcast/index.md | 17 + .../docs/docs/admin/broadcast/newsletter.md | 65 + mkdocs/docs/docs/admin/broadcast/sms.md | 64 + mkdocs/docs/docs/admin/dashboard.md | 22 + mkdocs/docs/docs/admin/index.md | 95 +- mkdocs/docs/docs/admin/map/areas.md | 37 + mkdocs/docs/docs/admin/map/canvassing.md | 45 + mkdocs/docs/docs/admin/map/data-quality.md | 25 + mkdocs/docs/docs/admin/map/index.md | 20 + mkdocs/docs/docs/admin/map/locations.md | 41 + mkdocs/docs/docs/admin/map/settings.md | 23 + mkdocs/docs/docs/admin/map/shifts.md | 39 + mkdocs/docs/docs/admin/media/ads.md | 25 + mkdocs/docs/docs/admin/media/analytics.md | 47 + mkdocs/docs/docs/admin/media/curated.md | 36 + mkdocs/docs/docs/admin/media/index.md | 19 + mkdocs/docs/docs/admin/media/library.md | 48 + mkdocs/docs/docs/admin/media/moderation.md | 50 + mkdocs/docs/docs/admin/payments/donations.md | 39 + mkdocs/docs/docs/admin/payments/index.md | 26 + mkdocs/docs/docs/admin/payments/plans.md | 25 + mkdocs/docs/docs/admin/payments/products.md | 24 + mkdocs/docs/docs/admin/payments/settings.md | 33 + mkdocs/docs/docs/admin/people-access.md | 131 + mkdocs/docs/docs/admin/services/index.md | 18 + .../docs/docs/admin/services/integrations.md | 89 + mkdocs/docs/docs/admin/services/monitoring.md | 63 + mkdocs/docs/docs/admin/services/tunnel.md | 44 + .../docs/admin/services/user-provisioning.md | 43 + mkdocs/docs/docs/admin/settings.md | 113 + mkdocs/docs/docs/admin/web/documentation.md | 49 + mkdocs/docs/docs/admin/web/homepage.md | 43 + mkdocs/docs/docs/admin/web/index.md | 24 + mkdocs/docs/docs/admin/web/landing-pages.md | 29 + mkdocs/docs/docs/admin/web/navigation.md | 53 + mkdocs/docs/docs/features/index.md | 251 -- mkdocs/docs/docs/getting-started/features.md | 289 ++ .../docs/docs/getting-started/first-steps.md | 80 + mkdocs/docs/docs/getting-started/index.md | 7 +- .../docs/docs/getting-started/installation.md | 61 + mkdocs/docs/docs/index.md | 2 +- mkdocs/docs/docs/phil.md | 115 +- mkdocs/docs/docs/services/index.md | 15 +- mkdocs/docs/docs/user-guide/campaigns.md | 41 + mkdocs/docs/docs/user-guide/donations.md | 37 + mkdocs/docs/docs/user-guide/events.md | 40 + mkdocs/docs/docs/user-guide/gallery.md | 41 + mkdocs/docs/docs/user-guide/index.md | 65 + mkdocs/docs/docs/user-guide/map.md | 24 + mkdocs/docs/docs/user-guide/profile.md | 49 + mkdocs/docs/docs/user-guide/shifts.md | 49 + mkdocs/docs/docs/user-guide/shop.md | 36 + mkdocs/docs/docs/volunteer/achievements.md | 88 + mkdocs/docs/docs/volunteer/canvassing.md | 57 + mkdocs/docs/docs/volunteer/index.md | 90 +- mkdocs/docs/docs/volunteer/shifts.md | 36 + mkdocs/docs/docs/volunteer/social.md | 87 + mkdocs/docs/javascripts/ad-widgets.js | 262 ++ mkdocs/docs/javascripts/docs-comments.js | 479 +++ mkdocs/docs/overrides/lander.html | 435 +- mkdocs/docs/overrides/main.html | 268 +- mkdocs/docs/stylesheets/docs-comments.css | 301 ++ mkdocs/docs/test.md | 26 + mkdocs/mkdocs.yml | 76 +- mkdocs/site/404.html | 284 +- mkdocs/site/assets/css/image-gallery.css | 437 ++ .../images/social/comments/callback.png | Bin 0 -> 64477 bytes .../social/docs/admin/advocacy/campaigns.png | Bin 0 -> 81434 bytes .../docs/admin/advocacy/email-queue.png | Bin 0 -> 65369 bytes .../social/docs/admin/advocacy/index.png | Bin 0 -> 68951 bytes .../docs/admin/advocacy/representatives.png | Bin 0 -> 70329 bytes .../social/docs/admin/advocacy/responses.png | Bin 0 -> 76661 bytes .../docs/admin/broadcast/email-templates.png | Bin 0 -> 68252 bytes .../social/docs/admin/broadcast/index.png | Bin 0 -> 70530 bytes .../docs/admin/broadcast/newsletter.png | Bin 0 -> 74906 bytes .../social/docs/admin/broadcast/sms.png | Bin 0 -> 75860 bytes .../images/social/docs/admin/dashboard.png | Bin 0 -> 70885 bytes .../assets/images/social/docs/admin/index.png | Bin 68526 -> 70130 bytes .../images/social/docs/admin/map/areas.png | Bin 0 -> 72831 bytes .../social/docs/admin/map/canvassing.png | Bin 0 -> 70588 bytes .../social/docs/admin/map/data-quality.png | Bin 0 -> 69165 bytes .../images/social/docs/admin/map/index.png | Bin 0 -> 74009 bytes .../social/docs/admin/map/locations.png | Bin 0 -> 66758 bytes .../images/social/docs/admin/map/settings.png | Bin 0 -> 74393 bytes .../images/social/docs/admin/map/shifts.png | Bin 0 -> 67649 bytes .../images/social/docs/admin/media/ads.png | Bin 0 -> 71486 bytes .../social/docs/admin/media/analytics.png | Bin 0 -> 70053 bytes .../social/docs/admin/media/curated.png | Bin 0 -> 71637 bytes .../images/social/docs/admin/media/index.png | Bin 0 -> 66646 bytes .../social/docs/admin/media/library.png | Bin 0 -> 68261 bytes .../social/docs/admin/media/moderation.png | Bin 0 -> 68917 bytes .../social/docs/admin/payments/donations.png | Bin 0 -> 68503 bytes .../social/docs/admin/payments/index.png | Bin 0 -> 69232 bytes .../social/docs/admin/payments/plans.png | Bin 0 -> 63964 bytes .../social/docs/admin/payments/products.png | Bin 0 -> 68892 bytes .../social/docs/admin/payments/settings.png | Bin 0 -> 75544 bytes .../social/docs/admin/people-access.png | Bin 0 -> 69536 bytes .../social/docs/admin/services/index.png | Bin 0 -> 65862 bytes .../docs/admin/services/integrations.png | Bin 0 -> 72809 bytes .../social/docs/admin/services/monitoring.png | Bin 0 -> 68232 bytes .../social/docs/admin/services/tunnel.png | Bin 0 -> 71164 bytes .../docs/admin/services/user-provisioning.png | Bin 0 -> 68349 bytes .../images/social/docs/admin/settings.png | Bin 0 -> 75069 bytes .../social/docs/admin/web/documentation.png | Bin 0 -> 70880 bytes .../images/social/docs/admin/web/homepage.png | Bin 0 -> 74214 bytes .../images/social/docs/admin/web/index.png | Bin 0 -> 69891 bytes .../social/docs/admin/web/landing-pages.png | Bin 0 -> 68641 bytes .../social/docs/admin/web/navigation.png | Bin 0 -> 71566 bytes .../images/social/docs/features/index.png | Bin 72368 -> 0 bytes .../social/docs/getting-started/features.png | Bin 0 -> 68519 bytes .../docs/getting-started/first-steps.png | Bin 0 -> 68355 bytes .../docs/getting-started/installation.png | Bin 0 -> 64260 bytes .../site/assets/images/social/docs/phil.png | Bin 0 -> 69887 bytes .../social/docs/user-guide/campaigns.png | Bin 0 -> 70610 bytes .../social/docs/user-guide/donations.png | Bin 0 -> 68827 bytes .../images/social/docs/user-guide/events.png | Bin 0 -> 75084 bytes .../images/social/docs/user-guide/gallery.png | Bin 0 -> 68035 bytes .../images/social/docs/user-guide/index.png | Bin 0 -> 66779 bytes .../images/social/docs/user-guide/map.png | Bin 0 -> 65131 bytes .../images/social/docs/user-guide/profile.png | Bin 0 -> 80320 bytes .../images/social/docs/user-guide/shifts.png | Bin 0 -> 68456 bytes .../images/social/docs/user-guide/shop.png | Bin 0 -> 70160 bytes .../social/docs/volunteer/achievements.png | Bin 0 -> 81026 bytes .../social/docs/volunteer/canvassing.png | Bin 0 -> 72312 bytes .../images/social/docs/volunteer/index.png | Bin 69119 -> 70735 bytes .../images/social/docs/volunteer/shifts.png | Bin 0 -> 69026 bytes .../images/social/docs/volunteer/social.png | Bin 0 -> 75071 bytes mkdocs/site/assets/js/env-config.js | 2 + mkdocs/site/assets/js/gancio-events.js | 131 + mkdocs/site/assets/js/image-gallery.js | 611 +++ .../repo-data/admin-changemaker.lite.json | 4 +- .../repo-data/anthropics-claude-code.json | 10 +- .../assets/repo-data/coder-code-server.json | 10 +- .../repo-data/gethomepage-homepage.json | 8 +- .../site/assets/repo-data/go-gitea-gitea.json | 10 +- .../site/assets/repo-data/knadh-listmonk.json | 8 +- .../site/assets/repo-data/lyqht-mini-qr.json | 8 +- 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 | 10 +- .../repo-data/squidfunk-mkdocs-material.json | 8 +- mkdocs/site/blog/index.html | 290 +- mkdocs/site/comments/callback/index.html | 1045 +++++ .../docs/admin/advocacy/campaigns/index.html | 2777 ++++++++++++ .../admin/advocacy/email-queue/index.html | 2607 +++++++++++ mkdocs/site/docs/admin/advocacy/index.html | 2517 +++++++++++ .../admin/advocacy/representatives/index.html | 2606 +++++++++++ .../docs/admin/advocacy/responses/index.html | 2607 +++++++++++ .../broadcast/email-templates/index.html | 2696 ++++++++++++ mkdocs/site/docs/admin/broadcast/index.html | 2486 +++++++++++ .../admin/broadcast/newsletter/index.html | 2701 ++++++++++++ .../site/docs/admin/broadcast/sms/index.html | 2728 ++++++++++++ mkdocs/site/docs/admin/dashboard/index.html | 2423 +++++++++++ mkdocs/site/docs/admin/index.html | 1122 ++++- mkdocs/site/docs/admin/map/areas/index.html | 2699 ++++++++++++ .../site/docs/admin/map/canvassing/index.html | 2727 ++++++++++++ .../docs/admin/map/data-quality/index.html | 2667 ++++++++++++ mkdocs/site/docs/admin/map/index.html | 2579 +++++++++++ .../site/docs/admin/map/locations/index.html | 2722 ++++++++++++ .../site/docs/admin/map/settings/index.html | 2665 ++++++++++++ mkdocs/site/docs/admin/map/shifts/index.html | 2723 ++++++++++++ mkdocs/site/docs/admin/media/ads/index.html | 2637 ++++++++++++ .../docs/admin/media/analytics/index.html | 2698 ++++++++++++ .../site/docs/admin/media/curated/index.html | 2667 ++++++++++++ mkdocs/site/docs/admin/media/index.html | 2548 +++++++++++ .../site/docs/admin/media/library/index.html | 2721 ++++++++++++ .../docs/admin/media/moderation/index.html | 2719 ++++++++++++ .../docs/admin/payments/donations/index.html | 2640 ++++++++++++ mkdocs/site/docs/admin/payments/index.html | 2535 +++++++++++ .../site/docs/admin/payments/plans/index.html | 2607 +++++++++++ .../docs/admin/payments/products/index.html | 2606 +++++++++++ .../docs/admin/payments/settings/index.html | 2635 ++++++++++++ .../site/docs/admin/people-access/index.html | 2938 +++++++++++++ mkdocs/site/docs/admin/services/index.html | 2517 +++++++++++ .../admin/services/integrations/index.html | 2770 ++++++++++++ .../docs/admin/services/monitoring/index.html | 2723 ++++++++++++ .../docs/admin/services/tunnel/index.html | 2665 ++++++++++++ .../services/user-provisioning/index.html | 2687 ++++++++++++ mkdocs/site/docs/admin/settings/index.html | 2707 ++++++++++++ .../docs/admin/web/documentation/index.html | 2670 ++++++++++++ .../site/docs/admin/web/homepage/index.html | 2664 ++++++++++++ mkdocs/site/docs/admin/web/index.html | 2531 +++++++++++ .../docs/admin/web/landing-pages/index.html | 2633 ++++++++++++ .../site/docs/admin/web/navigation/index.html | 2690 ++++++++++++ mkdocs/site/docs/api/index.html | 464 +- mkdocs/site/docs/architecture/index.html | 464 +- mkdocs/site/docs/deployment/index.html | 493 ++- .../environment-variables/index.html | 878 +++- .../docs/getting-started/features/index.html | 2296 ++++++++++ .../getting-started/first-steps/index.html | 2184 ++++++++++ mkdocs/site/docs/getting-started/index.html | 567 ++- .../getting-started/installation/index.html | 2103 +++++++++ mkdocs/site/docs/index.html | 466 +- mkdocs/site/docs/phil/index.html | 2075 +++++++++ mkdocs/site/docs/services/index.html | 549 ++- mkdocs/site/docs/troubleshooting/index.html | 470 +- .../site/docs/user-guide/campaigns/index.html | 2186 ++++++++++ .../site/docs/user-guide/donations/index.html | 2164 ++++++++++ mkdocs/site/docs/user-guide/events/index.html | 2188 ++++++++++ .../site/docs/user-guide/gallery/index.html | 2190 ++++++++++ .../docs/{features => user-guide}/index.html | 1273 +++--- mkdocs/site/docs/user-guide/map/index.html | 2131 +++++++++ .../site/docs/user-guide/profile/index.html | 2217 ++++++++++ mkdocs/site/docs/user-guide/shifts/index.html | 2194 ++++++++++ mkdocs/site/docs/user-guide/shop/index.html | 2162 ++++++++++ .../docs/volunteer/achievements/index.html | 2338 ++++++++++ .../site/docs/volunteer/canvassing/index.html | 2141 +++++++++ mkdocs/site/docs/volunteer/index.html | 765 ++-- mkdocs/site/docs/volunteer/shifts/index.html | 2042 +++++++++ mkdocs/site/docs/volunteer/social/index.html | 2231 ++++++++++ .../env_config_hook.cpython-311.pyc | Bin 6894 -> 7399 bytes mkdocs/site/hooks/env_config_hook.py | 9 + mkdocs/site/index.html | 442 +- mkdocs/site/javascripts/ad-widgets.js | 262 ++ mkdocs/site/javascripts/docs-comments.js | 479 +++ mkdocs/site/lander/index.html | 442 +- mkdocs/site/main/index.html | 284 +- mkdocs/site/overrides/lander.html | 442 +- mkdocs/site/overrides/main.html | 268 +- mkdocs/site/search/search_index.json | 2 +- mkdocs/site/sitemap.xml | 268 +- mkdocs/site/sitemap.xml.gz | Bin 329 -> 717 bytes mkdocs/site/stylesheets/docs-comments.css | 301 ++ mkdocs/site/test/index.html | 307 +- nginx/conf.d/default.conf | 49 + nginx/conf.d/default.conf.template | 49 + nginx/conf.d/services.conf | 124 +- nginx/conf.d/services.conf.template | 124 +- scripts/mkdocs-build-trigger.py | 81 + scripts/mkdocs-entrypoint.sh | 13 + scripts/upgrade.sh | 44 +- 727 files changed, 217309 insertions(+), 5801 deletions(-) create mode 100644 .mcp.json create mode 100644 SOCIAL_SYSTEM_PLAN.md create mode 100644 admin/src/components/AdBanner.tsx create mode 100644 admin/src/components/PublicNavBar.tsx create mode 100644 admin/src/components/PublicSearchModal.tsx create mode 100644 admin/src/components/calendar/EventSubmissionForm.tsx create mode 100644 admin/src/components/calendar/UnifiedCalendar.tsx create mode 100644 admin/src/components/chat/ChatPanel.tsx create mode 100644 admin/src/components/chat/ChatPanelTray.tsx create mode 100644 admin/src/components/chat/ChatWidgetFAB.tsx create mode 100644 admin/src/components/chat/RocketChatWidget.tsx create mode 100644 admin/src/components/command-palette/CommandPalette.tsx create mode 100644 admin/src/components/command-palette/registry.ts create mode 100644 admin/src/components/command-palette/types.ts create mode 100644 admin/src/components/command-palette/useCommandIndex.ts create mode 100644 admin/src/components/command-palette/useEntitySearch.ts create mode 100644 admin/src/components/dashboard/GiteaActivityCard.tsx create mode 100644 admin/src/components/dashboard/RocketChatStatsCard.tsx create mode 100644 admin/src/components/dashboard/UpcomingMeetingsCard.tsx create mode 100644 admin/src/components/dashboard/VaultwardenAdoptionCard.tsx create mode 100644 admin/src/components/map/EventLayerToggle.tsx create mode 100644 admin/src/components/map/EventMarkers.tsx create mode 100644 admin/src/components/media/AdPickerModal.tsx create mode 100644 admin/src/components/media/PhotoInsertModal.tsx create mode 100644 admin/src/components/media/PhotoPickerModal.tsx create mode 100644 admin/src/components/people/ConnectionForm.tsx create mode 100644 admin/src/components/people/ConnectionGraph.tsx create mode 100644 admin/src/components/people/ContactAddressPanel.tsx create mode 100644 admin/src/components/people/ContactEmailPanel.tsx create mode 100644 admin/src/components/people/ContactPhonePanel.tsx create mode 100644 admin/src/components/people/CreateUserFromContactModal.tsx create mode 100644 admin/src/components/people/DuplicateDetectionDrawer.tsx create mode 100644 admin/src/components/people/EngagementSummaryPanel.tsx create mode 100644 admin/src/components/people/HouseholdPanel.tsx create mode 100644 admin/src/components/people/MergeContactModal.tsx create mode 100644 admin/src/components/people/PersonActivityTimeline.tsx create mode 100644 admin/src/components/people/PersonCard.tsx create mode 100644 admin/src/components/people/PersonDetailDrawer.tsx create mode 100644 admin/src/components/people/ProfileLinkActions.tsx create mode 100644 admin/src/components/people/ProfilePreviewModal.tsx create mode 100644 admin/src/components/people/TagManagementModal.tsx create mode 100644 admin/src/components/people/UserAccountStatusPanel.tsx create mode 100644 admin/src/components/people/VideoCallModal.tsx create mode 100644 admin/src/components/public/ActivityFeed.tsx create mode 100644 admin/src/components/public/HomepageSection.tsx create mode 100644 admin/src/components/public/NewsletterSignup.tsx create mode 100644 admin/src/components/public/RelatedContent.tsx create mode 100644 admin/src/components/public/ShiftSignupModal.tsx create mode 100644 admin/src/components/social/FeedCard.tsx create mode 100644 admin/src/components/social/FriendButton.tsx create mode 100644 admin/src/components/social/FriendSuggestions.tsx create mode 100644 admin/src/components/social/FriendsAttendingBadge.tsx create mode 100644 admin/src/components/social/FriendsCampaignBadge.tsx create mode 100644 admin/src/components/social/FriendsOnMap.tsx create mode 100644 admin/src/components/social/GroupCard.tsx create mode 100644 admin/src/components/social/MessageButton.tsx create mode 100644 admin/src/components/social/NotificationBell.tsx create mode 100644 admin/src/components/social/OnlineIndicator.tsx create mode 100644 admin/src/components/social/PokeButton.tsx create mode 100644 admin/src/components/social/RecommendVideoModal.tsx create mode 100644 admin/src/components/social/SocialNetworkGraph.tsx create mode 100644 admin/src/components/social/SocialUserNode.tsx create mode 100644 admin/src/components/social/UserAvatar.tsx create mode 100644 admin/src/hooks/useDocumentTitle.ts create mode 100644 admin/src/hooks/usePageAds.ts create mode 100644 admin/src/hooks/usePostalCode.ts create mode 100644 admin/src/hooks/useSSE.ts create mode 100644 admin/src/pages/DocsCommentsPage.tsx create mode 100644 admin/src/pages/JitsiAuthPage.tsx create mode 100644 admin/src/pages/JitsiMeetPage.tsx create mode 100644 admin/src/pages/NavigationSettingsPage.tsx create mode 100644 admin/src/pages/PeoplePage.tsx create mode 100644 admin/src/pages/media/AdAnalyticsDashboardPage.tsx create mode 100644 admin/src/pages/payments/DonationPagesPage.tsx create mode 100644 admin/src/pages/payments/PlansPage.tsx create mode 100644 admin/src/pages/public/ContactProfilePage.tsx create mode 100644 admin/src/pages/public/DonationPagesListPage.tsx create mode 100644 admin/src/pages/public/EventsPage.tsx create mode 100644 admin/src/pages/public/HomePage.tsx create mode 100644 admin/src/pages/public/MeetingJoinPage.tsx create mode 100644 admin/src/pages/public/PagesIndexPage.tsx create mode 100644 admin/src/pages/public/PlanDetailPage.tsx create mode 100644 admin/src/pages/public/ProductDetailPage.tsx create mode 100644 admin/src/pages/sms/SmsCampaignsPage.tsx create mode 100644 admin/src/pages/sms/SmsContactsPage.tsx create mode 100644 admin/src/pages/sms/SmsConversationsPage.tsx create mode 100644 admin/src/pages/sms/SmsDashboardPage.tsx create mode 100644 admin/src/pages/sms/SmsSetupPage.tsx create mode 100644 admin/src/pages/social/SocialDashboardPage.tsx create mode 100644 admin/src/pages/social/SocialGraphPage.tsx create mode 100644 admin/src/pages/social/SocialModerationPage.tsx create mode 100644 admin/src/pages/volunteer/AchievementsPage.tsx create mode 100644 admin/src/pages/volunteer/DiscoverPage.tsx create mode 100644 admin/src/pages/volunteer/FriendsPage.tsx create mode 100644 admin/src/pages/volunteer/GroupDetailPage.tsx create mode 100644 admin/src/pages/volunteer/NotificationsPage.tsx create mode 100644 admin/src/pages/volunteer/SocialFeedPage.tsx create mode 100644 admin/src/pages/volunteer/SocialProfilePage.tsx create mode 100644 admin/src/stores/chat-widget.store.ts create mode 100644 admin/src/stores/command-palette.store.ts create mode 100644 admin/src/stores/favorites.store.ts create mode 100644 admin/src/stores/social.store.ts create mode 100644 admin/src/types/sms.ts create mode 100644 admin/src/types/social.ts create mode 100644 admin/src/utils/badges.ts create mode 100644 admin/src/utils/menu-items.ts create mode 100644 admin/src/utils/photoCardHtml.ts create mode 100755 api/prisma/init-gitea-db.sh create mode 100644 api/prisma/migrations/20260224100000_baseline_catchup/migration.sql create mode 100644 api/prisma/migrations/20260224185504_add_ad_placements/migration.sql create mode 100644 api/prisma/migrations/20260224185906_add_volunteer_engagement_sms_crm_linking/migration.sql create mode 100644 api/prisma/migrations/20260224190210_add_landing_page_listed_and_homepage_tagline/migration.sql create mode 100644 api/prisma/migrations/20260224215259_add_enable_social/migration.sql create mode 100644 api/prisma/migrations/20260224232546_add_digest_frequency/migration.sql create mode 100644 api/prisma/migrations/20260225000017_add_social_groups/migration.sql create mode 100644 api/prisma/migrations/20260225010931_add_enable_meet/migration.sql create mode 100644 api/prisma/migrations/20260225202515_add_meetings_model/migration.sql create mode 100644 api/prisma/migrations/20260225220000_add_shift_group_meeting_relations/migration.sql create mode 100644 api/src/modules/activity/activity-public.routes.ts create mode 100644 api/src/modules/activity/activity-public.service.ts create mode 100644 api/src/modules/docs-comments/docs-comments.routes.ts create mode 100644 api/src/modules/docs-comments/docs-comments.schemas.ts create mode 100644 api/src/modules/docs-comments/docs-comments.service.ts create mode 100644 api/src/modules/events/events-public.routes.ts create mode 100644 api/src/modules/events/unified-calendar.service.ts create mode 100644 api/src/modules/homepage/homepage.routes.ts create mode 100644 api/src/modules/homepage/homepage.service.ts create mode 100644 api/src/modules/jitsi/jitsi.routes.ts create mode 100644 api/src/modules/jitsi/jitsi.utils.ts create mode 100644 api/src/modules/map/events/events.routes.ts create mode 100644 api/src/modules/map/events/events.service.ts create mode 100644 api/src/modules/newsletter/newsletter-public.routes.ts create mode 100644 api/src/modules/og/og.routes.ts create mode 100644 api/src/modules/payments/donation-pages-admin.routes.ts create mode 100644 api/src/modules/payments/donation-pages-public.routes.ts create mode 100644 api/src/modules/payments/donation-pages.schemas.ts create mode 100644 api/src/modules/payments/donation-pages.service.ts create mode 100644 api/src/modules/payments/plans.service.ts create mode 100644 api/src/modules/people/people.routes.ts create mode 100644 api/src/modules/people/people.schemas.ts create mode 100644 api/src/modules/people/people.service.ts create mode 100644 api/src/modules/people/profile-public.routes.ts create mode 100644 api/src/modules/people/profile-public.schemas.ts create mode 100644 api/src/modules/people/profile.service.ts create mode 100644 api/src/modules/people/tags.schemas.ts create mode 100644 api/src/modules/people/tags.service.ts create mode 100644 api/src/modules/search/search.routes.ts create mode 100644 api/src/modules/search/search.service.ts create mode 100644 api/src/modules/sms/campaigns/sms-campaigns.routes.ts create mode 100644 api/src/modules/sms/campaigns/sms-campaigns.schemas.ts create mode 100644 api/src/modules/sms/campaigns/sms-campaigns.service.ts create mode 100644 api/src/modules/sms/contacts/sms-contacts.routes.ts create mode 100644 api/src/modules/sms/contacts/sms-contacts.schemas.ts create mode 100644 api/src/modules/sms/contacts/sms-contacts.service.ts create mode 100644 api/src/modules/sms/conversations/sms-conversations.routes.ts create mode 100644 api/src/modules/sms/conversations/sms-conversations.service.ts create mode 100644 api/src/modules/sms/device/sms-device.routes.ts create mode 100644 api/src/modules/sms/device/sms-device.service.ts create mode 100644 api/src/modules/sms/messages/sms-messages.routes.ts create mode 100644 api/src/modules/sms/messages/sms-messages.service.ts create mode 100644 api/src/modules/sms/setup/sms-setup.routes.ts create mode 100644 api/src/modules/social/achievements.routes.ts create mode 100644 api/src/modules/social/achievements.service.ts create mode 100644 api/src/modules/social/block.routes.ts create mode 100644 api/src/modules/social/block.service.ts create mode 100644 api/src/modules/social/feed.routes.ts create mode 100644 api/src/modules/social/feed.service.ts create mode 100644 api/src/modules/social/friendship.routes.ts create mode 100644 api/src/modules/social/friendship.service.ts create mode 100644 api/src/modules/social/group.routes.ts create mode 100644 api/src/modules/social/group.service.ts create mode 100644 api/src/modules/social/integration.routes.ts create mode 100644 api/src/modules/social/integration.service.ts create mode 100644 api/src/modules/social/messaging.service.ts create mode 100644 api/src/modules/social/notification.routes.ts create mode 100644 api/src/modules/social/notification.service.ts create mode 100644 api/src/modules/social/poke.routes.ts create mode 100644 api/src/modules/social/poke.service.ts create mode 100644 api/src/modules/social/presence.service.ts create mode 100644 api/src/modules/social/privacy.routes.ts create mode 100644 api/src/modules/social/privacy.service.ts create mode 100644 api/src/modules/social/profile.routes.ts create mode 100644 api/src/modules/social/recommendation.routes.ts create mode 100644 api/src/modules/social/recommendation.service.ts create mode 100644 api/src/modules/social/social-admin.routes.ts create mode 100644 api/src/modules/social/social-admin.service.ts create mode 100644 api/src/modules/social/social.middleware.ts create mode 100644 api/src/modules/social/social.rate-limits.ts create mode 100644 api/src/modules/social/social.routes.ts create mode 100644 api/src/modules/social/social.schemas.ts create mode 100644 api/src/modules/social/sse.routes.ts create mode 100644 api/src/modules/social/sse.service.ts create mode 100644 api/src/modules/social/suggestions.routes.ts create mode 100644 api/src/modules/social/suggestions.service.ts create mode 100644 api/src/modules/users/provisioning.routes.ts create mode 100644 api/src/services/gancio-settings-sync.service.ts create mode 100644 api/src/services/gitea.client.ts create mode 100644 api/src/services/reengagement.service.ts create mode 100644 api/src/services/sms-device-monitor.service.ts create mode 100644 api/src/services/sms-queue.service.ts create mode 100644 api/src/services/sms-response-sync.service.ts create mode 100644 api/src/services/social-digest.service.ts create mode 100644 api/src/services/tailscale.client.ts create mode 100644 api/src/services/termux.client.ts create mode 100644 api/src/services/user-provisioning/gitea.provisioner.ts create mode 100644 api/src/services/user-provisioning/index.ts create mode 100644 api/src/services/user-provisioning/listmonk.provisioner.ts create mode 100644 api/src/services/user-provisioning/provisioner.interface.ts create mode 100644 api/src/services/user-provisioning/provisioning.service.ts create mode 100644 api/src/services/user-provisioning/rocketchat.provisioner.ts create mode 100644 api/src/services/user-provisioning/vaultwarden.provisioner.ts create mode 100644 api/src/services/vaultwarden.client.ts create mode 100644 api/src/templates/email/profile-link.html create mode 100644 api/src/templates/email/profile-link.txt create mode 100644 api/src/templates/email/social-digest.html create mode 100644 api/src/templates/email/social-digest.txt create mode 100644 api/src/templates/email/volunteer-reengagement.html create mode 100644 api/src/templates/email/volunteer-reengagement.txt create mode 100644 api/src/templates/email/volunteer-shift-thank-you.html create mode 100644 api/src/templates/email/volunteer-shift-thank-you.txt create mode 100644 api/src/utils/escapeHtml.ts create mode 100644 api/src/utils/slug.ts create mode 100644 changemaker-control-panel/api/prisma/migrations/20260222041732_add_pangolin_endpoint/migration.sql create mode 100644 changemaker-control-panel/api/prisma/migrations/20260222050000_add_secrets_viewed_audit_action/migration.sql create mode 100644 changemaker-control-panel/api/prisma/migrations/20260225185415_add_meet_sms_social_people_flags/migration.sql rename configs/grafana/{ => dashboards}/api-performance.json (100%) rename configs/grafana/{ => dashboards}/application-overview.json (99%) rename configs/grafana/{ => dashboards}/dashboards.yml (100%) rename configs/grafana/{ => dashboards}/system-health.json (100%) rename configs/grafana/{ => datasources}/datasources.yml (100%) create mode 100644 mcp-server/package-lock.json create mode 100644 mcp-server/package.json create mode 100644 mcp-server/src/api-client.ts create mode 100644 mcp-server/src/index.ts create mode 100644 mcp-server/src/server.ts create mode 100644 mcp-server/src/tool-registry.ts create mode 100644 mcp-server/src/tools/campaigns.ts create mode 100644 mcp-server/src/tools/composite/daily-briefing.ts create mode 100644 mcp-server/src/tools/composite/outreach-report.ts create mode 100644 mcp-server/src/tools/composite/volunteer-summary.ts create mode 100644 mcp-server/src/tools/dashboard.ts create mode 100644 mcp-server/src/tools/locations.ts create mode 100644 mcp-server/src/tools/meta.ts create mode 100644 mcp-server/src/tools/packs/admin.ts create mode 100644 mcp-server/src/tools/packs/email.ts create mode 100644 mcp-server/src/tools/packs/media.ts create mode 100644 mcp-server/src/tools/packs/payments.ts create mode 100644 mcp-server/src/tools/packs/sms.ts create mode 100644 mcp-server/src/tools/people.ts create mode 100644 mcp-server/src/tools/settings.ts create mode 100644 mcp-server/src/tools/shifts.ts create mode 100644 mcp-server/tsconfig.json create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/comments/callback.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/admin/advocacy/campaigns.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/admin/advocacy/email-queue.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/admin/advocacy/index.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/admin/advocacy/representatives.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/admin/advocacy/responses.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/admin/broadcast/email-templates.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/admin/broadcast/index.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/admin/broadcast/newsletter.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/admin/broadcast/sms.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/admin/dashboard.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/admin/map/areas.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/admin/map/canvassing.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/admin/map/data-quality.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/admin/map/index.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/admin/map/locations.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/admin/map/settings.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/admin/map/shifts.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/admin/media/ads.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/admin/media/analytics.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/admin/media/curated.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/admin/media/index.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/admin/media/library.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/admin/media/moderation.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/admin/payments/donations.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/admin/payments/index.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/admin/payments/plans.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/admin/payments/products.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/admin/payments/settings.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/admin/people-access.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/admin/services/index.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/admin/services/integrations.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/admin/services/monitoring.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/admin/services/tunnel.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/admin/services/user-provisioning.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/admin/settings.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/admin/web/documentation.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/admin/web/homepage.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/admin/web/index.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/admin/web/landing-pages.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/admin/web/navigation.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/features/achievements.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/features/automation.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/features/campaigns.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/features/chat.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/features/command-palette.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/features/contact-profile.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/features/data-quality.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/features/docs-analytics.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/features/docs-comments.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/features/email-templates.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/features/events.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/features/gallery-ads.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/features/homepage.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/features/landing-pages.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/features/map-canvassing.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/features/media-manager.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/features/navigation.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/features/newsletter.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/features/og-sharing.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/features/password-manager.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/features/payments.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/features/people.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/features/quick-join.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/features/settings.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/features/sms.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/features/social.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/features/user-provisioning.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/features/video-conferencing.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/features/whiteboard.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/getting-started/features.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/getting-started/first-steps.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/getting-started/installation.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/user-guide/campaigns.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/user-guide/donations.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/user-guide/events.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/user-guide/gallery.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/user-guide/index.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/user-guide/map.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/user-guide/profile.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/user-guide/shifts.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/user-guide/shop.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/volunteer/achievements.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/volunteer/canvassing.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/volunteer/shifts.png create mode 100644 mkdocs/.cache/plugin/social/assets/images/social/docs/volunteer/social.png create mode 100644 mkdocs/docs/assets/css/image-gallery.css create mode 100644 mkdocs/docs/assets/js/image-gallery.js create mode 100644 mkdocs/docs/comments/callback.md create mode 100644 mkdocs/docs/docs/admin/advocacy/campaigns.md create mode 100644 mkdocs/docs/docs/admin/advocacy/email-queue.md create mode 100644 mkdocs/docs/docs/admin/advocacy/index.md create mode 100644 mkdocs/docs/docs/admin/advocacy/representatives.md create mode 100644 mkdocs/docs/docs/admin/advocacy/responses.md create mode 100644 mkdocs/docs/docs/admin/broadcast/email-templates.md create mode 100644 mkdocs/docs/docs/admin/broadcast/index.md create mode 100644 mkdocs/docs/docs/admin/broadcast/newsletter.md create mode 100644 mkdocs/docs/docs/admin/broadcast/sms.md create mode 100644 mkdocs/docs/docs/admin/dashboard.md create mode 100644 mkdocs/docs/docs/admin/map/areas.md create mode 100644 mkdocs/docs/docs/admin/map/canvassing.md create mode 100644 mkdocs/docs/docs/admin/map/data-quality.md create mode 100644 mkdocs/docs/docs/admin/map/index.md create mode 100644 mkdocs/docs/docs/admin/map/locations.md create mode 100644 mkdocs/docs/docs/admin/map/settings.md create mode 100644 mkdocs/docs/docs/admin/map/shifts.md create mode 100644 mkdocs/docs/docs/admin/media/ads.md create mode 100644 mkdocs/docs/docs/admin/media/analytics.md create mode 100644 mkdocs/docs/docs/admin/media/curated.md create mode 100644 mkdocs/docs/docs/admin/media/index.md create mode 100644 mkdocs/docs/docs/admin/media/library.md create mode 100644 mkdocs/docs/docs/admin/media/moderation.md create mode 100644 mkdocs/docs/docs/admin/payments/donations.md create mode 100644 mkdocs/docs/docs/admin/payments/index.md create mode 100644 mkdocs/docs/docs/admin/payments/plans.md create mode 100644 mkdocs/docs/docs/admin/payments/products.md create mode 100644 mkdocs/docs/docs/admin/payments/settings.md create mode 100644 mkdocs/docs/docs/admin/people-access.md create mode 100644 mkdocs/docs/docs/admin/services/index.md create mode 100644 mkdocs/docs/docs/admin/services/integrations.md create mode 100644 mkdocs/docs/docs/admin/services/monitoring.md create mode 100644 mkdocs/docs/docs/admin/services/tunnel.md create mode 100644 mkdocs/docs/docs/admin/services/user-provisioning.md create mode 100644 mkdocs/docs/docs/admin/settings.md create mode 100644 mkdocs/docs/docs/admin/web/documentation.md create mode 100644 mkdocs/docs/docs/admin/web/homepage.md create mode 100644 mkdocs/docs/docs/admin/web/index.md create mode 100644 mkdocs/docs/docs/admin/web/landing-pages.md create mode 100644 mkdocs/docs/docs/admin/web/navigation.md delete mode 100644 mkdocs/docs/docs/features/index.md create mode 100644 mkdocs/docs/docs/getting-started/features.md create mode 100644 mkdocs/docs/docs/getting-started/first-steps.md create mode 100644 mkdocs/docs/docs/getting-started/installation.md create mode 100644 mkdocs/docs/docs/user-guide/campaigns.md create mode 100644 mkdocs/docs/docs/user-guide/donations.md create mode 100644 mkdocs/docs/docs/user-guide/events.md create mode 100644 mkdocs/docs/docs/user-guide/gallery.md create mode 100644 mkdocs/docs/docs/user-guide/index.md create mode 100644 mkdocs/docs/docs/user-guide/map.md create mode 100644 mkdocs/docs/docs/user-guide/profile.md create mode 100644 mkdocs/docs/docs/user-guide/shifts.md create mode 100644 mkdocs/docs/docs/user-guide/shop.md create mode 100644 mkdocs/docs/docs/volunteer/achievements.md create mode 100644 mkdocs/docs/docs/volunteer/canvassing.md create mode 100644 mkdocs/docs/docs/volunteer/shifts.md create mode 100644 mkdocs/docs/docs/volunteer/social.md create mode 100644 mkdocs/docs/javascripts/ad-widgets.js create mode 100644 mkdocs/docs/javascripts/docs-comments.js create mode 100644 mkdocs/docs/stylesheets/docs-comments.css create mode 100644 mkdocs/site/assets/css/image-gallery.css create mode 100644 mkdocs/site/assets/images/social/comments/callback.png create mode 100644 mkdocs/site/assets/images/social/docs/admin/advocacy/campaigns.png create mode 100644 mkdocs/site/assets/images/social/docs/admin/advocacy/email-queue.png create mode 100644 mkdocs/site/assets/images/social/docs/admin/advocacy/index.png create mode 100644 mkdocs/site/assets/images/social/docs/admin/advocacy/representatives.png create mode 100644 mkdocs/site/assets/images/social/docs/admin/advocacy/responses.png create mode 100644 mkdocs/site/assets/images/social/docs/admin/broadcast/email-templates.png create mode 100644 mkdocs/site/assets/images/social/docs/admin/broadcast/index.png create mode 100644 mkdocs/site/assets/images/social/docs/admin/broadcast/newsletter.png create mode 100644 mkdocs/site/assets/images/social/docs/admin/broadcast/sms.png create mode 100644 mkdocs/site/assets/images/social/docs/admin/dashboard.png create mode 100644 mkdocs/site/assets/images/social/docs/admin/map/areas.png create mode 100644 mkdocs/site/assets/images/social/docs/admin/map/canvassing.png create mode 100644 mkdocs/site/assets/images/social/docs/admin/map/data-quality.png create mode 100644 mkdocs/site/assets/images/social/docs/admin/map/index.png create mode 100644 mkdocs/site/assets/images/social/docs/admin/map/locations.png create mode 100644 mkdocs/site/assets/images/social/docs/admin/map/settings.png create mode 100644 mkdocs/site/assets/images/social/docs/admin/map/shifts.png create mode 100644 mkdocs/site/assets/images/social/docs/admin/media/ads.png create mode 100644 mkdocs/site/assets/images/social/docs/admin/media/analytics.png create mode 100644 mkdocs/site/assets/images/social/docs/admin/media/curated.png create mode 100644 mkdocs/site/assets/images/social/docs/admin/media/index.png create mode 100644 mkdocs/site/assets/images/social/docs/admin/media/library.png create mode 100644 mkdocs/site/assets/images/social/docs/admin/media/moderation.png create mode 100644 mkdocs/site/assets/images/social/docs/admin/payments/donations.png create mode 100644 mkdocs/site/assets/images/social/docs/admin/payments/index.png create mode 100644 mkdocs/site/assets/images/social/docs/admin/payments/plans.png create mode 100644 mkdocs/site/assets/images/social/docs/admin/payments/products.png create mode 100644 mkdocs/site/assets/images/social/docs/admin/payments/settings.png create mode 100644 mkdocs/site/assets/images/social/docs/admin/people-access.png create mode 100644 mkdocs/site/assets/images/social/docs/admin/services/index.png create mode 100644 mkdocs/site/assets/images/social/docs/admin/services/integrations.png create mode 100644 mkdocs/site/assets/images/social/docs/admin/services/monitoring.png create mode 100644 mkdocs/site/assets/images/social/docs/admin/services/tunnel.png create mode 100644 mkdocs/site/assets/images/social/docs/admin/services/user-provisioning.png create mode 100644 mkdocs/site/assets/images/social/docs/admin/settings.png create mode 100644 mkdocs/site/assets/images/social/docs/admin/web/documentation.png create mode 100644 mkdocs/site/assets/images/social/docs/admin/web/homepage.png create mode 100644 mkdocs/site/assets/images/social/docs/admin/web/index.png create mode 100644 mkdocs/site/assets/images/social/docs/admin/web/landing-pages.png create mode 100644 mkdocs/site/assets/images/social/docs/admin/web/navigation.png delete mode 100644 mkdocs/site/assets/images/social/docs/features/index.png create mode 100644 mkdocs/site/assets/images/social/docs/getting-started/features.png create mode 100644 mkdocs/site/assets/images/social/docs/getting-started/first-steps.png create mode 100644 mkdocs/site/assets/images/social/docs/getting-started/installation.png create mode 100644 mkdocs/site/assets/images/social/docs/phil.png create mode 100644 mkdocs/site/assets/images/social/docs/user-guide/campaigns.png create mode 100644 mkdocs/site/assets/images/social/docs/user-guide/donations.png create mode 100644 mkdocs/site/assets/images/social/docs/user-guide/events.png create mode 100644 mkdocs/site/assets/images/social/docs/user-guide/gallery.png create mode 100644 mkdocs/site/assets/images/social/docs/user-guide/index.png create mode 100644 mkdocs/site/assets/images/social/docs/user-guide/map.png create mode 100644 mkdocs/site/assets/images/social/docs/user-guide/profile.png create mode 100644 mkdocs/site/assets/images/social/docs/user-guide/shifts.png create mode 100644 mkdocs/site/assets/images/social/docs/user-guide/shop.png create mode 100644 mkdocs/site/assets/images/social/docs/volunteer/achievements.png create mode 100644 mkdocs/site/assets/images/social/docs/volunteer/canvassing.png create mode 100644 mkdocs/site/assets/images/social/docs/volunteer/shifts.png create mode 100644 mkdocs/site/assets/images/social/docs/volunteer/social.png create mode 100644 mkdocs/site/assets/js/gancio-events.js create mode 100644 mkdocs/site/assets/js/image-gallery.js create mode 100644 mkdocs/site/comments/callback/index.html create mode 100644 mkdocs/site/docs/admin/advocacy/campaigns/index.html create mode 100644 mkdocs/site/docs/admin/advocacy/email-queue/index.html create mode 100644 mkdocs/site/docs/admin/advocacy/index.html create mode 100644 mkdocs/site/docs/admin/advocacy/representatives/index.html create mode 100644 mkdocs/site/docs/admin/advocacy/responses/index.html create mode 100644 mkdocs/site/docs/admin/broadcast/email-templates/index.html create mode 100644 mkdocs/site/docs/admin/broadcast/index.html create mode 100644 mkdocs/site/docs/admin/broadcast/newsletter/index.html create mode 100644 mkdocs/site/docs/admin/broadcast/sms/index.html create mode 100644 mkdocs/site/docs/admin/dashboard/index.html create mode 100644 mkdocs/site/docs/admin/map/areas/index.html create mode 100644 mkdocs/site/docs/admin/map/canvassing/index.html create mode 100644 mkdocs/site/docs/admin/map/data-quality/index.html create mode 100644 mkdocs/site/docs/admin/map/index.html create mode 100644 mkdocs/site/docs/admin/map/locations/index.html create mode 100644 mkdocs/site/docs/admin/map/settings/index.html create mode 100644 mkdocs/site/docs/admin/map/shifts/index.html create mode 100644 mkdocs/site/docs/admin/media/ads/index.html create mode 100644 mkdocs/site/docs/admin/media/analytics/index.html create mode 100644 mkdocs/site/docs/admin/media/curated/index.html create mode 100644 mkdocs/site/docs/admin/media/index.html create mode 100644 mkdocs/site/docs/admin/media/library/index.html create mode 100644 mkdocs/site/docs/admin/media/moderation/index.html create mode 100644 mkdocs/site/docs/admin/payments/donations/index.html create mode 100644 mkdocs/site/docs/admin/payments/index.html create mode 100644 mkdocs/site/docs/admin/payments/plans/index.html create mode 100644 mkdocs/site/docs/admin/payments/products/index.html create mode 100644 mkdocs/site/docs/admin/payments/settings/index.html create mode 100644 mkdocs/site/docs/admin/people-access/index.html create mode 100644 mkdocs/site/docs/admin/services/index.html create mode 100644 mkdocs/site/docs/admin/services/integrations/index.html create mode 100644 mkdocs/site/docs/admin/services/monitoring/index.html create mode 100644 mkdocs/site/docs/admin/services/tunnel/index.html create mode 100644 mkdocs/site/docs/admin/services/user-provisioning/index.html create mode 100644 mkdocs/site/docs/admin/settings/index.html create mode 100644 mkdocs/site/docs/admin/web/documentation/index.html create mode 100644 mkdocs/site/docs/admin/web/homepage/index.html create mode 100644 mkdocs/site/docs/admin/web/index.html create mode 100644 mkdocs/site/docs/admin/web/landing-pages/index.html create mode 100644 mkdocs/site/docs/admin/web/navigation/index.html create mode 100644 mkdocs/site/docs/getting-started/features/index.html create mode 100644 mkdocs/site/docs/getting-started/first-steps/index.html create mode 100644 mkdocs/site/docs/getting-started/installation/index.html create mode 100644 mkdocs/site/docs/phil/index.html create mode 100644 mkdocs/site/docs/user-guide/campaigns/index.html create mode 100644 mkdocs/site/docs/user-guide/donations/index.html create mode 100644 mkdocs/site/docs/user-guide/events/index.html create mode 100644 mkdocs/site/docs/user-guide/gallery/index.html rename mkdocs/site/docs/{features => user-guide}/index.html (62%) create mode 100644 mkdocs/site/docs/user-guide/map/index.html create mode 100644 mkdocs/site/docs/user-guide/profile/index.html create mode 100644 mkdocs/site/docs/user-guide/shifts/index.html create mode 100644 mkdocs/site/docs/user-guide/shop/index.html create mode 100644 mkdocs/site/docs/volunteer/achievements/index.html create mode 100644 mkdocs/site/docs/volunteer/canvassing/index.html create mode 100644 mkdocs/site/docs/volunteer/shifts/index.html create mode 100644 mkdocs/site/docs/volunteer/social/index.html create mode 100644 mkdocs/site/javascripts/ad-widgets.js create mode 100644 mkdocs/site/javascripts/docs-comments.js create mode 100644 mkdocs/site/stylesheets/docs-comments.css create mode 100644 scripts/mkdocs-build-trigger.py create mode 100755 scripts/mkdocs-entrypoint.sh diff --git a/.env.example b/.env.example index f2b5f1a9..3de5e4a3 100644 --- a/.env.example +++ b/.env.example @@ -159,6 +159,20 @@ GITEA_DB_ROOT_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS GITEA_ROOT_URL=https://git.cmlite.org GITEA_DOMAIN=git.cmlite.org +# --- Gitea Docs Comments --- +# Enable comments on MkDocs pages (backed by Gitea Issues) +GITEA_COMMENTS_ENABLED=false +# Personal access token with repo write scope (create in Gitea → Settings → Applications) +GITEA_API_TOKEN= +# Repository owner (Gitea username that will own the docs-comments repo) +GITEA_COMMENTS_REPO_OWNER= +# Repository name (auto-created via admin setup button) +GITEA_COMMENTS_REPO_NAME=docs-comments +# OAuth2 Application credentials (create in Gitea → Settings → Applications → OAuth2) +# Redirect URIs: https://{DOMAIN}/comments/callback/ and http://localhost:4003/comments/callback/ +GITEA_OAUTH_CLIENT_ID= +GITEA_OAUTH_CLIENT_SECRET= + # --- n8n --- N8N_URL=http://n8n-changemaker:5678 N8N_PORT=5678 @@ -289,6 +303,40 @@ GANCIO_ADMIN_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS # Enable automatic shift → Gancio event sync GANCIO_SYNC_ENABLED=false +# --- Jitsi Meet (Video Conferencing) --- +# Self-hosted Jitsi with JWT auth — integrates with Rocket.Chat for channel video calls +# ENABLE_MEET is the initial default; once saved in admin Settings, the DB value is authoritative +ENABLE_MEET=false +# JWT authentication (shared between Jitsi Prosody, Rocket.Chat, and the API) +# Generate with: openssl rand -hex 32 +JITSI_APP_ID=changemaker +JITSI_APP_SECRET=GENERATE_WITH_openssl_rand_hex_32 +# Internal XMPP passwords (used between Jitsi containers, not exposed externally) +# Generate each with: openssl rand -hex 16 +JITSI_JICOFO_AUTH_PASSWORD=GENERATE_WITH_openssl_rand_hex_16 +JITSI_JVB_AUTH_PASSWORD=GENERATE_WITH_openssl_rand_hex_16 +# Embed port for admin iframe +JITSI_EMBED_PORT=8893 +JITSI_URL=http://jitsi-web-changemaker:80 +# JVB public IP (required for NAT traversal — set to server's public IP in production) +JVB_ADVERTISE_IP= +# JVB UDP port for media traffic (must be open in firewall) +JVB_PORT=10000 + +# --- Monitoring Embed Ports (iframe embedding) --- +GRAFANA_EMBED_PORT=8894 +ALERTMANAGER_EMBED_PORT=8895 + +# --- SMS Campaigns (Termux Android Bridge) --- +# ENABLE_SMS is the initial default; once saved in admin Settings, the DB value is authoritative +ENABLE_SMS=false +TERMUX_API_URL=http://10.0.0.193:5001 +TERMUX_API_KEY= +SMS_DELAY_BETWEEN_MS=3000 +SMS_MAX_RETRIES=3 +SMS_RESPONSE_SYNC_INTERVAL_MS=30000 +SMS_DEVICE_MONITOR_INTERVAL_MS=30000 + # --- Monitoring (only used with --profile monitoring) --- PROMETHEUS_PORT=9090 GRAFANA_PORT=3005 diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000..a9c079b6 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,14 @@ +{ + "mcpServers": { + "changemaker": { + "command": "npx", + "args": ["tsx", "mcp-server/src/index.ts"], + "cwd": "/home/bunker-admin/changemaker.lite", + "env": { + "CML_API_URL": "http://localhost:4002", + "CML_SERVICE_EMAIL": "admin@bnkops.ca", + "CML_SERVICE_PASSWORD": "ChangeMe2025!" + } + } + } +} diff --git a/CLAUDE.md b/CLAUDE.md index a3693ffb..a884cbe4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,7 @@ Changemaker Lite is a self-hosted political campaign platform built with Docker - ✅ Observability Dashboard - ✅ **Drizzle to Prisma Migration Complete** (Media API consolidated to single-ORM, Feb 2026) - ✅ **Automated Pangolin Setup** (One-command tunnel deployment, Feb 2026) +- ✅ **Migration Drift Fixed** (Baseline catch-up migration, 14 migrations cover full schema, Feb 2026) - 🚧 Phase 15 (Testing + Polish) - Next --- @@ -598,7 +599,22 @@ cd api && npx tsc --noEmit && cd ../admin && npx tsc --noEmit - **Prisma** (main API): Use `UncheckedCreateInput`/`UncheckedUpdateInput` for foreign keys, `Prisma.InputJsonValue` for JSON arrays - **Drizzle** (media API): Separate schema file, push with `npx drizzle-kit push`, no migrations generated +### Prisma Migration Workflow +- **Always use `prisma migrate dev`** for schema changes (not `prisma db push`) — `db push` applies changes directly but doesn't create migration files, causing drift +- **Migration history:** 14 migrations in `api/prisma/migrations/` fully cover the schema (baseline catch-up applied Feb 2026) +- **Fixing drift:** If `db push` was used and migrations are out of sync: + 1. Drop any stray indexes/objects in DB not in schema: `DROP INDEX IF EXISTS ;` + 2. Create a temp shadow DB: `docker compose exec -T v2-postgres createdb -U changemaker prisma_shadow_diff` + 3. Generate catch-up SQL: `docker compose exec -T api npx prisma migrate diff --from-migrations ./prisma/migrations --to-schema-datamodel ./prisma/schema.prisma --shadow-database-url "postgresql://..." --script` + 4. Save to `api/prisma/migrations/_/migration.sql` + 5. Mark as applied: `docker compose exec -T api npx prisma migrate resolve --applied ` + 6. Verify: `docker compose exec -T api npx prisma migrate status` → "Database schema is up to date!" + 7. Clean up: `docker compose exec -T v2-postgres dropdb -U changemaker prisma_shadow_diff` +- **Gotcha:** `--from-migrations` replays all migration files on a shadow DB. If a migration references tables created by `db push` (no migration file), it will fail. Fix: temporarily move the dependent migration aside, generate the catch-up (which includes the missing tables), then remove the old migration +- **Production deploys:** Use `prisma migrate deploy` (not `migrate dev`) — it applies pending migrations without creating a shadow DB + ### V2-Specific Gotchas +- **Prisma migrations:** Never use `db push` on the v2 branch — always use `migrate dev` to keep migration history in sync. The baseline catch-up migration (`20260224100000_baseline_catchup`) covers all schema changes from Feb 18–24 that were previously applied via `db push` - Fastify media API on port 4100, separate from Express on 4000 (same DB, different ORM) - Volunteer page naming: `VolunteerShiftsPage.tsx` (not "MyAssignmentsPage") - Tracking module: `api/src/modules/map/tracking/` (volunteer + admin routes) @@ -744,6 +760,7 @@ V1 code archived in `influence/`, `map/`, and `docker-compose.v1.yml`. Two indep ### Database - `api/prisma/schema.prisma` — Main schema (30+ Prisma models) +- `api/prisma/migrations/` — 14 migration files (fully cover schema as of Feb 2026) - `api/drizzle.config.ts` — Drizzle config for media tables - `api/prisma/seed.ts` — Database seeding diff --git a/SOCIAL_SYSTEM_PLAN.md b/SOCIAL_SYSTEM_PLAN.md new file mode 100644 index 00000000..2e4ee5c6 --- /dev/null +++ b/SOCIAL_SYSTEM_PLAN.md @@ -0,0 +1,156 @@ +# Social Connections System — Implementation Plan + +See the full plan in the conversation transcript. This file tracks implementation progress. + +## Phase Status + +| Phase | Description | Status | +|-------|-------------|--------| +| 1 | Feature Flag + Social Module Skeleton | COMPLETE | +| 2 | Friendship API (Send, Accept, Decline, Cancel, Unfriend) | COMPLETE | +| 3 | Block/Unblock API + Privacy Settings API | COMPLETE | +| 4 | User Social Profile + Volunteer Portal UI Foundation | COMPLETE | +| 5 | In-App Notification System (Bell Icon + Dropdown) | COMPLETE | +| 6 | Social Activity Feed (Friends' Activities) | COMPLETE | +| 7 | CRM Bridge (Auto-Connect + Friend Suggestions) | COMPLETE | +| 8 | Poke System + Video Recommendations | COMPLETE | +| 9 | Close Friends + Friends Management Page | COMPLETE (merged into Phase 4 UI) | +| 10 | Email Digest Notifications | COMPLETE | +| 11 | Social Integration with Existing Features | COMPLETE | +| 12 | Rocket.Chat DM Integration | COMPLETE | +| 13 | Group/Team Features (Shift Teams, Campaign Teams) | COMPLETE | +| 14 | Gamification (Achievements, Streaks, Leaderboards) | COMPLETE | +| 15 | Real-Time Features (SSE for Live Notifications, Online Status) | COMPLETE | + +## Files Created/Modified + +### Backend (API) +- `api/prisma/schema.prisma` — added `enableSocial` to SiteSettings +- `api/prisma/migrations/20260224215259_add_enable_social/` — migration +- `api/src/modules/social/` — new module directory + - `social.routes.ts` — main router mounting sub-routers + - `social.schemas.ts` — Zod schemas (friendship, privacy, notification) + - `social.middleware.ts` — `checkSocialEnabled` feature gate + - `social.rate-limits.ts` — rate limiters (friend request, social action) + - `friendship.service.ts` — full friendship CRUD + notifications + - `friendship.routes.ts` — 10 friendship endpoints + - `block.service.ts` — block/unblock with auto-unfriend + - `block.routes.ts` — 3 block endpoints + - `privacy.service.ts` — privacy settings get/update (auto-create defaults) + - `privacy.routes.ts` — 2 privacy endpoints + - `notification.service.ts` — notification CRUD + preferences (respects opt-outs) + - `notification.routes.ts` — 7 notification endpoints + - `profile.routes.ts` — user profile view (own + other, privacy-filtered) +- `api/src/modules/settings/settings.schemas.ts` — added `enableSocial` +- `api/src/server.ts` — mounted socialRouter at `/api/social` + +### Frontend (Admin) +- `admin/src/types/social.ts` — TypeScript interfaces +- `admin/src/stores/social.store.ts` — Zustand social store +- `admin/src/components/social/` — new directory + - `UserAvatar.tsx` — initials avatar with userId-based color + - `FriendButton.tsx` — context-aware friend action button + - `NotificationBell.tsx` — bell icon + dropdown (30s polling) +- `admin/src/pages/volunteer/` — new pages + - `SocialProfilePage.tsx` — own + other user profile + - `FriendsPage.tsx` — friends management (tabs: friends, requests, sent, blocked) + - `NotificationsPage.tsx` — full notification list + preferences +- `admin/src/components/VolunteerLayout.tsx` — added NotificationBell +- `admin/src/components/VolunteerFooterNav.tsx` — added "Friends" nav item +- `admin/src/components/FeatureGate.tsx` — added `enableSocial` +- `admin/src/types/api.ts` — added `enableSocial` to SiteSettings +- `admin/src/pages/SettingsPage.tsx` — added toggle +- `admin/src/App.tsx` — added 6 new volunteer routes + +### Phase 6 — Social Activity Feed +- `api/src/modules/social/feed.service.ts` — aggregates 4 activity types + Redis cache (2-min TTL) +- `api/src/modules/social/feed.routes.ts` — GET `/` (friend feed), GET `/my` (own activity) +- `admin/src/components/social/FeedCard.tsx` — activity card with type-based icon/color +- `admin/src/pages/volunteer/SocialFeedPage.tsx` — feed page with suggestions widget + +### Phase 7 — CRM Bridge + Suggestions +- `api/src/modules/social/suggestions.service.ts` — ranked suggestions (household/mutual/shifts/campaigns) +- `api/src/modules/social/suggestions.routes.ts` — GET `/`, POST `/:userId/dismiss` +- `admin/src/components/social/FriendSuggestions.tsx` — horizontal scroll suggestions widget +- `admin/src/pages/volunteer/DiscoverPage.tsx` — search + suggestions page + +### Phase 8 — Poke System + Video Recommendations +- `api/src/modules/social/poke.service.ts` — poke CRUD + 24h Redis cooldown per pair +- `api/src/modules/social/poke.routes.ts` — POST `/`, GET `/`, GET `/count`, POST `/:id/read`, GET `/cooldown/:userId` +- `api/src/modules/social/recommendation.service.ts` — video recommendation CRUD + duplicate detection +- `api/src/modules/social/recommendation.routes.ts` — POST `/`, GET `/`, GET `/sent`, GET `/count`, POST `/:id/read` +- `admin/src/components/social/PokeButton.tsx` — poke button with cooldown indicator +- `admin/src/components/social/RecommendVideoModal.tsx` — friend + video picker modal +- `admin/src/pages/volunteer/SocialProfilePage.tsx` — added PokeButton + +### Phase 10 — Email Digest Notifications +- `api/prisma/migrations/20260224232546_add_digest_frequency/` — adds digestFrequency + lastDigestSentAt +- `api/src/services/social-digest.service.ts` — daily scan, generates digest emails +- `api/src/templates/email/social-digest.html` + `.txt` — digest email templates +- `api/src/server.ts` — added daily social digest scan interval +- `admin/src/pages/volunteer/NotificationsPage.tsx` — added digest frequency selector + +### Phase 11 — Social Integration with Existing Features +- `api/src/modules/social/integration.service.ts` — friends on shifts, campaigns, and active map sessions (privacy-filtered) +- `api/src/modules/social/integration.routes.ts` — 3 endpoints: shifts/:id/friends, campaigns/:id/friends, map/friends +- `admin/src/components/social/FriendsAttendingBadge.tsx` — "N friends attending" badge with stacked avatars +- `admin/src/components/social/FriendsCampaignBadge.tsx` — "N friends participated" badge with stacked avatars +- `admin/src/components/social/FriendsOnMap.tsx` — floating panel showing friends currently canvassing (60s poll) +- `admin/src/pages/public/ShiftsPage.tsx` — added FriendsAttendingBadge per shift card +- `admin/src/pages/public/CampaignPage.tsx` — added FriendsCampaignBadge in hero section +- `admin/src/pages/volunteer/VolunteerShiftsPage.tsx` — added FriendsAttendingBadge per shift card +- `admin/src/pages/volunteer/VolunteerMapPage.tsx` — added FriendsOnMap overlay +- `admin/src/types/social.ts` — added FriendOnShift, FriendOnCampaign, FriendOnMap types + +### Phase 12 — Rocket.Chat DM Integration +- `api/src/modules/social/messaging.service.ts` — openDM: provisions both users, creates DM room, returns roomId +- `api/src/modules/social/profile.routes.ts` — added POST `/:userId/dm` endpoint +- `api/src/services/rocketchat.client.ts` — added `createDM(usernames)` method +- `admin/src/components/social/MessageButton.tsx` — DM button (opens chat widget, RC-gated) +- `admin/src/pages/volunteer/SocialProfilePage.tsx` — added MessageButton for accepted friends + +### Phase 13 — Group/Team Features +- `api/prisma/schema.prisma` — added SocialGroup, SocialGroupMember models + SocialGroupType enum + User.socialGroupMemberships relation +- `api/prisma/migrations/20260225000017_add_social_groups/` — migration creating social_groups + social_group_members tables +- `api/src/modules/social/group.service.ts` — getOrCreate, syncShiftTeam, syncCampaignTeam, listMyGroups, getGroupDetail +- `api/src/modules/social/group.routes.ts` — GET `/` (my groups), GET `/:id` (group detail) +- `api/src/modules/social/social.routes.ts` — mounted groupRouter at `/groups` +- `api/src/modules/map/shifts/shifts.service.ts` — added fire-and-forget groupService.syncShiftTeam() on all signup/cancel events +- `api/src/modules/influence/campaign-emails/campaign-emails.service.ts` — added fire-and-forget groupService.syncCampaignTeam() on email creation +- `admin/src/types/social.ts` — added SocialGroupSummary, SocialGroupDetail interfaces +- `admin/src/components/social/GroupCard.tsx` — group card with type-based icon/color +- `admin/src/pages/volunteer/GroupDetailPage.tsx` — group detail with member list + FriendButton per member +- `admin/src/pages/volunteer/FriendsPage.tsx` — added "Groups" tab +- `admin/src/App.tsx` — added `/volunteer/groups/:id` route + +### Phase 14 — Gamification (Achievements, Streaks, Leaderboards) +- `api/src/modules/social/achievements.service.ts` — 11 achievements (4 shift, 4 canvass, 2 campaign, 2 social), checkAndUnlock, getLeaderboard (raw SQL), getVolunteerStats (on-the-fly computed) +- `api/src/modules/social/achievements.routes.ts` — 6 endpoints: achievements, definitions, stats, stats/:userId, user/:userId, leaderboard +- `api/src/modules/social/social.routes.ts` — mounted achievementsRouter at `/achievements` +- `api/src/modules/map/canvass/canvass.service.ts` — added achievements.checkAndUnlock after recordVisit +- `api/src/modules/map/shifts/shifts.service.ts` — added achievements.checkAndUnlock after signup events (admin, public, volunteer) +- `api/src/modules/influence/campaign-emails/campaign-emails.service.ts` — added achievements.checkAndUnlock after email creation +- `api/src/modules/social/friendship.service.ts` — added achievements.checkAndUnlock on friend accept (both users) +- `admin/src/types/social.ts` — added AchievementDef, AchievementWithProgress, VolunteerStats, LeaderboardEntry interfaces +- `admin/src/pages/volunteer/AchievementsPage.tsx` — badge gallery (locked/unlocked + progress bars), volunteer stats summary, leaderboard (canvass/shifts/campaigns tabs) +- `admin/src/pages/volunteer/SocialProfilePage.tsx` — added achievement badges section to own + other user profiles +- `admin/src/pages/volunteer/SocialFeedPage.tsx` — added top canvassers leaderboard widget +- `admin/src/App.tsx` — added `/volunteer/achievements` route + +### Phase 15 — Real-Time Features (SSE for Live Notifications, Online Status) +- `api/src/modules/social/sse.service.ts` — in-memory SSE connection manager (addClient, removeClient, sendToUser, sendToUsers, heartbeat, closeAll) +- `api/src/modules/social/presence.service.ts` — online/offline tracking with privacy filtering, broadcastPresence to friends, stale cleanup (5min timeout), markAllOffline (startup) +- `api/src/modules/social/sse.routes.ts` — GET `/` (SSE stream), GET `/online-friends`, GET `/status` +- `api/src/modules/social/social.routes.ts` — mounted sseRouter at `/sse`, added query-param token injection for EventSource auth +- `api/src/modules/social/notification.service.ts` — SSE push after notification creation (real-time delivery) +- `api/src/modules/social/friendship.service.ts` — SSE push on friend_request + friend_accepted events +- `api/src/modules/social/poke.service.ts` — SSE push on poke events +- `api/src/server.ts` — SSE heartbeat start, presenceService.markAllOffline on startup, 1-min stale cleanup interval, sseService.closeAll on graceful shutdown +- `admin/src/hooks/useSSE.ts` — EventSource hook with auto-reconnect (exponential backoff), handles notification/presence/friend_request/friend_accepted/poke events +- `admin/src/components/social/OnlineIndicator.tsx` — green dot showing online status for friends +- `admin/src/components/social/UserAvatar.tsx` — added showOnline prop with OnlineIndicator overlay +- `admin/src/stores/social.store.ts` — added onlineFriends state + fetchOnlineFriends action +- `admin/src/components/VolunteerLayout.tsx` — initialized useSSE() on mount +- `admin/src/components/social/NotificationBell.tsx` — reduced polling to 2-min fallback (SSE handles real-time) +- `admin/src/pages/volunteer/FriendsPage.tsx` — enabled showOnline on friend list avatars +- `admin/src/pages/volunteer/SocialProfilePage.tsx` — enabled showOnline on other user profile avatars diff --git a/admin/index.html b/admin/index.html index 3ab8eecb..69d1f3b0 100644 --- a/admin/index.html +++ b/admin/index.html @@ -3,7 +3,14 @@ - Changemaker Lite - Admin + Changemaker Lite + + + + + + +
diff --git a/admin/package-lock.json b/admin/package-lock.json index db98f27b..a012765d 100644 --- a/admin/package-lock.json +++ b/admin/package-lock.json @@ -10,10 +10,14 @@ "dependencies": { "@ant-design/icons": "^5.6.0", "@ant-design/v5-patch-for-react-19": "^1.0.3", + "@dagrejs/dagre": "^2.0.4", "@monaco-editor/react": "^4.7.0", + "@types/d3-force": "^3.0.10", "@types/dompurify": "^3.2.0", + "@xyflow/react": "^12.10.1", "antd": "^5.23.0", "axios": "^1.7.9", + "d3-force": "^3.0.0", "dayjs": "^1.11.19", "dompurify": "^3.3.1", "grapesjs": "^0.22.14", @@ -424,6 +428,19 @@ "node": ">=6.9.0" } }, + "node_modules/@dagrejs/dagre": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-2.0.4.tgz", + "integrity": "sha512-J6vCWTNpicHF4zFlZG1cS5DkGzMr9941gddYkakjrg3ZNev4bbqEgLHFTWiFrcJm7UCRu7olO3K6IRDd9gSGhA==", + "dependencies": { + "@dagrejs/graphlib": "3.0.4" + } + }, + "node_modules/@dagrejs/graphlib": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-3.0.4.tgz", + "integrity": "sha512-HxZ7fCvAwTLCWCO0WjDkzAFQze8LdC6iOpKbetDKHIuDfIgMlIzYzqZ4nxwLlclQX+3ZVeZ1K2OuaOE2WWcyOg==" + }, "node_modules/@emotion/hash": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", @@ -1499,11 +1516,24 @@ "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "dependencies": { + "@types/d3-selection": "*" + } + }, "node_modules/@types/d3-ease": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==" }, + "node_modules/@types/d3-force": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@types/d3-force/-/d3-force-3.0.10.tgz", + "integrity": "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==" + }, "node_modules/@types/d3-interpolate": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", @@ -1525,6 +1555,11 @@ "@types/d3-time": "*" } }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==" + }, "node_modules/@types/d3-shape": { "version": "3.1.8", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", @@ -1543,6 +1578,23 @@ "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, "node_modules/@types/dompurify": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.2.0.tgz", @@ -1646,6 +1698,63 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@xyflow/react": { + "version": "12.10.1", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.1.tgz", + "integrity": "sha512-5eSWtIK/+rkldOuFbOOz44CRgQRjtS9v5nufk77DV+XBnfCGL9HAQ8PG00o2ZYKqkEU/Ak6wrKC95Tu+2zuK3Q==", + "dependencies": { + "@xyflow/system": "0.0.75", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/react/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.75", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.75.tgz", + "integrity": "sha512-iXs+AGFLi8w/VlAoc/iSxk+CxfT6o64Uw/k0CKASOPqjqz6E0rb5jFZgJtXGZCpfQI6OQpu5EnumP5fGxQheaQ==", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/antd": { "version": "5.29.3", "resolved": "https://registry.npmjs.org/antd/-/antd-5.29.3.tgz", @@ -1817,6 +1926,11 @@ } ] }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==" + }, "node_modules/classnames": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", @@ -1906,6 +2020,26 @@ "node": ">=12" } }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-ease": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", @@ -1914,6 +2048,19 @@ "node": ">=12" } }, + "node_modules/d3-force": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz", + "integrity": "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-format": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", @@ -1941,6 +2088,14 @@ "node": ">=12" } }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-scale": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", @@ -1956,6 +2111,14 @@ "node": ">=12" } }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-shape": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", @@ -1997,6 +2160,39 @@ "node": ">=12" } }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/dayjs": { "version": "1.11.19", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", diff --git a/admin/package.json b/admin/package.json index 41d0e86d..e7b944b9 100644 --- a/admin/package.json +++ b/admin/package.json @@ -11,10 +11,14 @@ "dependencies": { "@ant-design/icons": "^5.6.0", "@ant-design/v5-patch-for-react-19": "^1.0.3", + "@dagrejs/dagre": "^2.0.4", "@monaco-editor/react": "^4.7.0", + "@types/d3-force": "^3.0.10", "@types/dompurify": "^3.2.0", + "@xyflow/react": "^12.10.1", "antd": "^5.23.0", "axios": "^1.7.9", + "d3-force": "^3.0.0", "dayjs": "^1.11.19", "dompurify": "^3.3.1", "grapesjs": "^0.22.14", diff --git a/admin/src/App.tsx b/admin/src/App.tsx index dccd9742..854af482 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -38,23 +38,32 @@ import ExcalidrawPage from '@/pages/ExcalidrawPage'; import VaultwardenPage from '@/pages/VaultwardenPage'; import RocketChatPage from '@/pages/RocketChatPage'; import GancioPage from '@/pages/GancioPage'; +import JitsiMeetPage from '@/pages/JitsiMeetPage'; import SettingsPage from '@/pages/SettingsPage'; +import NavigationSettingsPage from '@/pages/NavigationSettingsPage'; import PangolinPage from '@/pages/PangolinPage'; import ObservabilityPage from '@/pages/ObservabilityPage'; import DocsAnalyticsPage from '@/pages/DocsAnalyticsPage'; +import DocsCommentsPage from '@/pages/DocsCommentsPage'; import PaymentsDashboardPage from '@/pages/payments/PaymentsDashboardPage'; import SubscribersPage from '@/pages/payments/SubscribersPage'; import PaymentProductsPage from '@/pages/payments/ProductsPage'; import PaymentDonationsPage from '@/pages/payments/DonationsPage'; +import DonationPagesPage from '@/pages/payments/DonationPagesPage'; +import PlansPage from '@/pages/payments/PlansPage'; import PaymentSettingsPage from '@/pages/payments/PaymentSettingsPage'; import LibraryPage from '@/pages/media/LibraryPage'; import AnalyticsDashboardPage from '@/pages/media/AnalyticsDashboardPage'; import MediaJobsPage from '@/pages/media/MediaJobsPage'; import CommentModerationPage from '@/pages/media/CommentModerationPage'; import GalleryAdsPage from '@/pages/media/GalleryAdsPage'; +import AdAnalyticsDashboardPage from '@/pages/media/AdAnalyticsDashboardPage'; import CampaignModerationPage from '@/pages/influence/CampaignModerationPage'; import CampaignEffectivenessPage from '@/pages/influence/CampaignEffectivenessPage'; import PublicLandingPage from '@/pages/public/LandingPage'; +import PagesIndexPage from '@/pages/public/PagesIndexPage'; +import EventsPage from '@/pages/public/EventsPage'; +import HomePage from '@/pages/public/HomePage'; import CampaignsListPage from '@/pages/public/CampaignsListPage'; import CampaignPage from '@/pages/public/CampaignPage'; import CreateCampaignPage from '@/pages/public/CreateCampaignPage'; @@ -73,21 +82,44 @@ 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 ProductDetailPage from '@/pages/public/ProductDetailPage'; +import PlanDetailPage from '@/pages/public/PlanDetailPage'; import DonatePage from '@/pages/public/DonatePage'; +import DonationPagesListPage from '@/pages/public/DonationPagesListPage'; import PaymentSuccessPage from '@/pages/public/PaymentSuccessPage'; import MyActivityPage from '@/pages/volunteer/MyActivityPage'; import VolunteerShiftsPage from '@/pages/volunteer/VolunteerShiftsPage'; import MyRoutesPage from '@/pages/volunteer/MyRoutesPage'; import VolunteerMapPage from '@/pages/volunteer/VolunteerMapPage'; +import FriendsPage from '@/pages/volunteer/FriendsPage'; +import SocialProfilePage from '@/pages/volunteer/SocialProfilePage'; +import NotificationsPage from '@/pages/volunteer/NotificationsPage'; +import SocialFeedPage from '@/pages/volunteer/SocialFeedPage'; +import DiscoverPage from '@/pages/volunteer/DiscoverPage'; +import GroupDetailPage from '@/pages/volunteer/GroupDetailPage'; +import AchievementsPage from '@/pages/volunteer/AchievementsPage'; import { ADMIN_ROLES } from '@/types/api'; import { isAdmin } from '@/utils/roles'; import QuickJoinPage from '@/pages/public/QuickJoinPage'; import VerifyEmailPage from '@/pages/VerifyEmailPage'; import ResetPasswordPage from '@/pages/ResetPasswordPage'; +import SmsDashboardPage from '@/pages/sms/SmsDashboardPage'; +import SmsContactsPage from '@/pages/sms/SmsContactsPage'; +import SmsCampaignsPage from '@/pages/sms/SmsCampaignsPage'; +import SmsConversationsPage from '@/pages/sms/SmsConversationsPage'; +import SmsSetupPage from '@/pages/sms/SmsSetupPage'; +import PeoplePage from '@/pages/PeoplePage'; +import ContactProfilePage from '@/pages/public/ContactProfilePage'; +import SocialDashboardPage from '@/pages/social/SocialDashboardPage'; +import SocialGraphPage from '@/pages/social/SocialGraphPage'; +import SocialModerationPage from '@/pages/social/SocialModerationPage'; +import MeetingJoinPage from '@/pages/public/MeetingJoinPage'; +import JitsiAuthPage from '@/pages/JitsiAuthPage'; +import CommandPalette from '@/components/command-palette/CommandPalette'; function RoleAwareRedirect() { const { user, isAuthenticated } = useAuthStore(); - if (!isAuthenticated) return ; + if (!isAuthenticated) return ; if (user && isAdmin(user)) return ; return ; } @@ -151,7 +183,13 @@ export default function App() { > + + {/* Public homepage */} + }> + } /> + + {/* Public pages (no auth, dark blue theme) — feature-gated */} }> } /> @@ -182,22 +220,41 @@ export default function App() { } /> } /> + }> + } /> + + {/* Public meeting join page — feature-gated */} + }> + } /> + + + }> + } /> + } /> {/* Public Payment pages (PublicLayout, dark blue theme) — feature-gated */} }> } /> + } /> }> } /> + } /> }> - } /> + } /> + } /> }> } /> + {/* Self-service contact profile (no auth, token-based access) */} + }> + } /> + + {/* Public Media Gallery (purple theme) — feature-gated */} }> } /> @@ -240,6 +297,14 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> @@ -251,6 +316,7 @@ export default function App() { } /> } /> + } /> } /> } /> } /> + + + + } + /> } /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> } /> + + + + } + /> + + + + } + /> } /> + + + + } + /> + {/* SMS Campaign Routes */} + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + } + /> + @@ -582,6 +759,14 @@ export default function App() { } /> + + + + } + /> } /> + + + + } + /> a.position ?? 0)); + const selected: GalleryAd[] = []; + const remaining = [...ads]; + + for (let i = 0; i < maxAds && remaining.length > 0; i++) { + // Build weights: lower position = higher weight + const weights = remaining.map((ad) => maxPosition - (ad.position ?? 0) + 1); + const totalWeight = weights.reduce((sum, w) => sum + w, 0); + + // Use a different hash offset per slot so multi-ad displays don't repeat + const pick = (seed + i * 7919) % totalWeight; + let cumulative = 0; + let chosenIdx = 0; + for (let j = 0; j < weights.length; j++) { + cumulative += (weights[j] ?? 0); + if (pick < cumulative) { + chosenIdx = j; + break; + } + } + + const chosen = remaining[chosenIdx]; + if (chosen) selected.push(chosen); + remaining.splice(chosenIdx, 1); + } + + return selected; +} + +/** + * Renders GalleryAdCard(s) in a centered container. + * Intended for use on public pages between content sections. + */ +export default function AdBanner({ ads, maxAds = 1, placement }: AdBannerProps) { + if (ads.length === 0) return null; + + const displayAds = placement ? selectAds(ads, maxAds, placement) : ads.slice(0, maxAds); + + return ( +
1 ? 800 : 400, + flexWrap: 'wrap', + }} + > + {displayAds.map((ad) => ( +
+ +
+ ))} +
+ ); +} diff --git a/admin/src/components/AppLayout.tsx b/admin/src/components/AppLayout.tsx index afd0f446..1655fd3d 100644 --- a/admin/src/components/AppLayout.tsx +++ b/admin/src/components/AppLayout.tsx @@ -18,6 +18,7 @@ import { HomeOutlined, ScissorOutlined, CalendarOutlined, + ScheduleOutlined, FileTextOutlined, NotificationOutlined, BookOutlined, @@ -42,6 +43,15 @@ import { CrownOutlined, PictureOutlined, LockOutlined, + PhoneOutlined, + TagOutlined, + SearchOutlined, + ContactsOutlined, + VideoCameraOutlined, + ApartmentOutlined, + SafetyOutlined, + StarFilled, + StarOutlined, } from '@ant-design/icons'; import type { MenuProps } from 'antd'; import { api } from '@/lib/api'; @@ -49,15 +59,97 @@ import { useAuthStore } from '@/stores/auth.store'; import { useSettingsStore } from '@/stores/settings.store'; import { hasAnyRole } from '@/utils/roles'; import type { PageHeaderConfig, AppOutletContext } from '@/types/api'; +import { buildHomeUrl, resolveNavUrl } from '@/lib/service-url'; +import type { NavItem } from '@/types/api'; +import { useCommandPaletteStore } from '@/stores/command-palette.store'; +import { useFavoritesStore } from '@/stores/favorites.store'; +import { resolveValidFavorites, collectLeafKeys } from '@/utils/menu-items'; +import RocketChatWidget from './chat/RocketChatWidget'; // Re-export for backward compatibility export type { PageHeaderConfig, AppOutletContext }; +/** Wrap a leaf menu item's label with a favorite star toggle */ +function FavoriteLabel({ label, itemKey }: { label: React.ReactNode; itemKey: string }) { + const { isFavorite, toggleFavorite } = useFavoritesStore(); + const starred = isFavorite(itemKey); + return ( + + {label} + { e.stopPropagation(); toggleFavorite(itemKey); }} + style={{ fontSize: 12, lineHeight: 1, cursor: 'pointer', flexShrink: 0 }} + > + {starred ? : } + + + ); +} + +/** Recursively walk menu items, wrapping leaf labels with FavoriteLabel stars */ +function addStarsToMenuItems(items: MenuProps['items'], leafKeys: Set): MenuProps['items'] { + if (!items) return items; + return items.map(item => { + if (!item) return item; + if ('children' in item && item.children) { + return { ...item, children: addStarsToMenuItems(item.children as MenuProps['items'], leafKeys) } as typeof item; + } + if ('key' in item && 'label' in item && item.key && leafKeys.has(item.key as string)) { + return { ...item, label: } as typeof item; + } + return item; + }); +} + const { Header, Sider, Content } = Layout; const { Text } = Typography; const { useBreakpoint } = Grid; -function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isSuperAdmin: boolean, badges?: { pendingResponses?: number }): MenuProps['items'] { +/** Default nav items for the admin header when navConfig is null */ +const DEFAULT_ADMIN_NAV_ITEMS: NavItem[] = [ + { id: 'home', label: 'Home', path: '/', icon: 'HomeOutlined', enabled: true, order: 0, type: 'builtin', external: true }, + { id: 'campaigns', label: 'Campaigns', path: '/campaigns', icon: 'SendOutlined', enabled: true, order: 1, type: 'builtin', featureFlag: 'enableInfluence' }, + { id: 'map', label: 'Map', path: '/map', icon: 'EnvironmentOutlined', enabled: true, order: 2, type: 'builtin', featureFlag: 'enableMap' }, + { id: 'shifts', label: 'Shifts', path: '/shifts', icon: 'ScheduleOutlined', enabled: true, order: 3, type: 'builtin', featureFlag: 'enableMap' }, + { id: 'gallery', label: 'Gallery', path: '/gallery', icon: 'PlayCircleOutlined', enabled: true, order: 5, type: 'builtin', featureFlag: 'enableMediaFeatures' }, + { id: 'pricing', label: 'Pricing', path: '/pricing', icon: 'DollarOutlined', enabled: true, order: 6, type: 'builtin', featureFlag: 'enablePayments' }, + { id: 'shop', label: 'Shop', path: '/shop', icon: 'ShoppingOutlined', enabled: true, order: 7, type: 'builtin', featureFlag: 'enablePayments' }, + { id: 'donate', label: 'Donate', path: '/donate', icon: 'HeartOutlined', enabled: true, order: 8, type: 'builtin', featureFlag: 'enablePayments' }, + { id: 'landing', label: 'Website', path: '$landing', icon: 'GlobalOutlined', enabled: false, order: 9, type: 'builtin', external: true }, + { id: 'docs', label: 'Docs', path: '$docs', icon: 'BookOutlined', enabled: false, order: 10, type: 'builtin', external: true }, +]; + +/** Map icon string IDs to Ant Design icon components for the admin header */ +const ADMIN_ICON_MAP: Record = { + HomeOutlined: , + SendOutlined: , + EnvironmentOutlined: , + CalendarOutlined: , + ScheduleOutlined: , + PlayCircleOutlined: , + HeartOutlined: , + DollarOutlined: , + ShoppingOutlined: , + LinkOutlined: , + GlobalOutlined: , + BookOutlined: , +}; + +/** Merge missing builtin defaults into stored navConfig items and sync icons */ +function mergeAdminNavDefaults(stored: NavItem[]): NavItem[] { + const defaultMap = new Map(DEFAULT_ADMIN_NAV_ITEMS.filter(d => d.type === 'builtin').map(d => [d.id, d])); + // Sync icon for existing builtin items so code-level icon changes propagate + const synced = stored.map(item => { + const def = defaultMap.get(item.id); + return (def && item.type === 'builtin') ? { ...item, icon: def.icon } : item; + }); + const ids = new Set(synced.map(i => i.id)); + const missing = DEFAULT_ADMIN_NAV_ITEMS.filter(d => d.type === 'builtin' && !ids.has(d.id)); + return missing.length > 0 ? [...synced, ...missing] : synced; +} + +function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isSuperAdmin: boolean, badges?: { pendingResponses?: number; pendingEmails?: number; pendingCampaignReview?: number; pendingComments?: number }): MenuProps['items'] { const items: MenuProps['items'] = [ { key: '/app', @@ -66,6 +158,28 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS }, ]; + // People & Access submenu — Users always visible, People gated by feature flag + { + const communityChildren: MenuProps['items'] = []; + if (settings?.enablePeople) { + communityChildren.push({ key: '/app/people', icon: , label: 'People' }); + } + communityChildren.push({ key: '/app/users', icon: , label: 'Users' }); + if (settings?.enableSocial) { + communityChildren.push( + { key: '/app/social', icon: , label: 'Social Dashboard' }, + { key: '/app/social/graph', icon: , label: 'Social Graph' }, + { key: '/app/social/moderation', icon: , label: 'Social Moderation' }, + ); + } + items.push({ + key: 'community-submenu', + icon: , + label: 'People & Access', + children: communityChildren, + }); + } + if (settings?.enableInfluence !== false) { items.push({ key: 'influence-submenu', @@ -73,9 +187,9 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS label: 'Advocacy', children: [ { key: '/app/campaigns', icon: , label: 'Campaigns' }, - { key: '/app/campaign-moderation', icon: , label: 'Campaign Review' }, + { key: '/app/campaign-moderation', icon: , label: badges?.pendingCampaignReview ? Campaign Review : 'Campaign Review' }, { key: '/app/representatives', icon: , label: 'Representatives' }, - { key: '/app/email-queue', icon: , label: 'Outgoing Emails' }, + { key: '/app/email-queue', icon: , label: badges?.pendingEmails ? Outgoing Emails : 'Outgoing Emails' }, { key: '/app/responses', icon: , label: badges?.pendingResponses ? Responses : 'Responses' }, { key: '/app/influence/effectiveness', icon: , label: 'Effectiveness' }, ], @@ -83,14 +197,28 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS } if (settings?.enableNewsletter !== false) { + const broadcastChildren: MenuProps['items'] = [ + { key: '/app/listmonk', icon: , label: 'Newsletter' }, + { key: '/app/email-templates', icon: , label: 'Email Templates' }, + ]; + if (settings?.enableSms || isSuperAdmin) { + broadcastChildren.push( + { key: '/app/sms/setup', icon: , label: 'SMS Setup' }, + ); + } + if (settings?.enableSms) { + broadcastChildren.push( + { key: '/app/sms', icon: , label: 'SMS Dashboard' }, + { key: '/app/sms/contacts', icon: , label: 'SMS Contacts' }, + { key: '/app/sms/campaigns', icon: , label: 'SMS Campaigns' }, + { key: '/app/sms/conversations', icon: , label: 'SMS Threads' }, + ); + } items.push({ key: 'broadcast-submenu', icon: , label: 'Broadcast', - children: [ - { key: '/app/listmonk', icon: , label: 'Newsletter' }, - { key: '/app/email-templates', icon: , label: 'Email Templates' }, - ], + children: broadcastChildren, }); } @@ -99,8 +227,10 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS if (settings?.enableLandingPages !== false) { webChildren.push({ key: '/app/pages', icon: , label: 'Landing Pages' }); } + webChildren.push({ key: '/app/navigation', icon: , label: 'Navigation' }); webChildren.push({ key: '/app/docs', icon: , label: 'Documentation' }); webChildren.push({ key: '/app/docs/analytics', icon: , label: 'Analytics' }); + webChildren.push({ key: '/app/docs/comments', icon: , label: badges?.pendingComments ? Comments : 'Comments' }); webChildren.push({ key: '/app/docs/settings', icon: , label: 'Docs Settings' }); webChildren.push({ key: '/app/code', icon: , label: 'Code Editor' }); items.push({ @@ -118,7 +248,7 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS children: [ { key: '/app/map', icon: , label: 'Locations' }, { key: '/app/map/data-quality', icon: , label: 'Data Quality' }, - { key: '/app/map/shifts', icon: , label: 'Shifts' }, + { key: '/app/map/shifts', icon: , label: 'Shifts' }, { key: '/app/map/cuts', icon: , label: 'Areas' }, { key: '/app/map/canvass', icon: , label: 'Canvassing' }, { key: '/app/map/settings', icon: , label: 'Settings' }, @@ -136,7 +266,6 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS { key: '/app/media/analytics', icon: , label: 'Analytics' }, { key: '/app/media/curated', icon: , label: 'Curated' }, { key: '/app/media/moderation', icon: , label: 'Moderation' }, - { key: '/app/media/gallery-ads', icon: , label: 'Gallery Ads' }, { key: '/app/media/jobs', icon: , label: 'Processing Jobs' }, ], }); @@ -149,9 +278,12 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS label: 'Payments', children: [ { key: '/app/payments', icon: , label: 'Dashboard' }, + { key: '/app/payments/plans', icon: , label: 'Plans' }, { key: '/app/payments/subscribers', icon: , label: 'Subscribers' }, { key: '/app/payments/products', icon: , label: 'Products' }, - { key: '/app/payments/donations', icon: , label: 'Donations' }, + { key: '/app/payments/donation-pages', icon: , label: 'Donation Pages' }, + { key: '/app/payments/donations', icon: , label: 'Donation Orders' }, + { key: '/app/payments/ads', icon: , label: 'Gallery Ads' }, { key: '/app/payments/settings', icon: , label: 'Settings' }, ], }); @@ -175,6 +307,7 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS { key: '/app/services/gitea', icon: , label: 'Git' }, { key: '/app/services/excalidraw', icon: , label: 'Whiteboard' }, ...(settings?.enableChat ? [{ key: '/app/services/rocketchat', icon: , label: 'Team Chat' }] : []), + ...(settings?.enableMeet ? [{ key: '/app/services/jitsi', icon: , label: 'Video Meet' }] : []), { key: '/app/services/gancio', icon: , label: 'Events' }, { key: '/app/services/miniqr', icon: , label: 'QR Codes' }, ]}, @@ -183,11 +316,6 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS } items.push( - { - key: '/app/users', - icon: , - label: 'Users', - }, { key: '/app/settings', icon: , @@ -210,11 +338,16 @@ export default function AppLayout() { const screens = useBreakpoint(); const isMobile = !screens.md; const isSuperAdmin = hasAnyRole(user, ['SUPER_ADMIN']); - const [pendingResponses, setPendingResponses] = useState(0); + const [badgeCounts, setBadgeCounts] = useState<{ pendingResponses: number; pendingEmails: number; pendingCampaignReview: number; pendingComments: number }>({ pendingResponses: 0, pendingEmails: 0, pendingCampaignReview: 0, pendingComments: 0 }); const fetchBadges = useCallback(() => { api.get('/dashboard/summary').then(({ data }) => { - setPendingResponses(data?.responses?.pending ?? 0); + setBadgeCounts({ + pendingResponses: data?.responses?.pending ?? 0, + pendingEmails: data?.emails?.queued ?? 0, + pendingCampaignReview: data?.campaignModeration?.pendingReview ?? 0, + pendingComments: data?.docsComments?.pending ?? 0, + }); }).catch(() => {}); }, []); @@ -224,10 +357,37 @@ export default function AppLayout() { return () => clearInterval(interval); }, [fetchBadges]); - const menuItems = buildMenuItems(settings, isSuperAdmin, { pendingResponses }); + const baseMenuItems = buildMenuItems(settings, isSuperAdmin, badgeCounts); + const { favorites } = useFavoritesStore(); + + // Build final menu: resolve favorites, add stars, prepend favorites section + const menuItems = (() => { + const leafKeys = collectLeafKeys(baseMenuItems); + const starredItems = addStarsToMenuItems(baseMenuItems, leafKeys); + + // Resolve favorites against current menu (handles feature-flag changes) + const validFavorites = resolveValidFavorites(baseMenuItems, favorites); + if (validFavorites.length === 0) return starredItems; + + const favSection: NonNullable = [ + { + type: 'group' as const, + label: 'Favorites', + children: validFavorites.map(fav => ({ + key: `fav:${fav.key}`, + icon: fav.icon, + label: fav.label, + })), + }, + { type: 'divider' as const }, + ]; + return [...favSection, ...(starredItems || [])]; + })(); const handleMenuClick: MenuProps['onClick'] = ({ key }) => { - navigate(key); + // Strip 'fav:' prefix from favorites section items + const route = key.startsWith('fav:') ? key.slice(4) : key; + navigate(route); if (isMobile) setDrawerOpen(false); }; @@ -253,44 +413,72 @@ export default function AppLayout() { }, ]; - // Match the current path to a menu key (supports submenus) + // Match the current path to a menu key (supports submenus and item groups) const selectedKey = (() => { const path = location.pathname; // Exact match first if (path === '/app') return '/app'; - // Check all items including children — longest match wins + // Check all items including children and group grandchildren — longest match wins let best = ''; + const checkKey = (k: string) => { + if (k.startsWith('/') && (path === k || path.startsWith(k + '/'))) { + if (k.length > best.length) best = k; + } + }; for (const item of menuItems || []) { if (!item || !('key' in item)) continue; if ('children' in item && item.children) { for (const child of item.children) { - if (!child || !('key' in child)) continue; - const k = child.key as string; - if (path === k || path.startsWith(k + '/')) { - if (k.length > best.length) best = k; + if (!child) continue; + // Handle item groups (type: 'group') — check their nested children + if ('type' in child && child.type === 'group' && 'children' in child && child.children) { + for (const grandchild of child.children) { + if (!grandchild || !('key' in grandchild)) continue; + checkKey(grandchild.key as string); + } + } else if ('key' in child) { + checkKey(child.key as string); } } } const k = item.key?.toString() || ''; - if (k.startsWith('/') && k !== '/app' && (path === k || path.startsWith(k + '/'))) { - if (k.length > best.length) best = k; - } + if (k !== '/app') checkKey(k); } return best || '/app'; })(); + // Also highlight the corresponding favorite item if present + const selectedKeys = favorites.includes(selectedKey) + ? [selectedKey, `fav:${selectedKey}`] + : [selectedKey]; + // Derive which submenus should be open based on active route const defaultOpenKeys = (() => { const path = location.pathname; const keys: string[] = []; for (const item of menuItems || []) { if (!item || !('children' in item) || !item.children) continue; + let found = false; for (const child of item.children) { - if (!child || !('key' in child)) continue; - const k = child.key as string; - if (path === k || path.startsWith(k + '/')) { - keys.push(item.key as string); - break; + if (found) break; + if (!child) continue; + // Handle item groups (type: 'group') — check their nested children + if ('type' in child && child.type === 'group' && 'children' in child && child.children) { + for (const grandchild of child.children) { + if (!grandchild || !('key' in grandchild)) continue; + const k = grandchild.key as string; + if (path === k || path.startsWith(k + '/')) { + keys.push(item.key as string); + found = true; + break; + } + } + } else if ('key' in child) { + const k = child.key as string; + if (path === k || path.startsWith(k + '/')) { + keys.push(item.key as string); + found = true; + } } } } @@ -333,7 +521,7 @@ export default function AppLayout() { + {isMobile ? (
@@ -391,103 +585,70 @@ export default function AppLayout() { )}
- {pageHeader?.actions} - + + icon={} + onClick={() => useCommandPaletteStore.getState().open()} + /> - {settings?.enableInfluence !== false && ( - - - - )} + {pageHeader?.actions} + {(() => { + const items = mergeAdminNavDefaults(settings?.navConfig?.items ?? DEFAULT_ADMIN_NAV_ITEMS); + const featureFlags: Record = { + enableInfluence: settings?.enableInfluence, + enableMap: settings?.enableMap, + enableMediaFeatures: settings?.enableMediaFeatures, + enablePayments: settings?.enablePayments, + enableEvents: settings?.enableEvents, + }; + return items + .filter(item => item.enabled) + .filter(item => { + if (!item.featureFlag) return true; + if (item.featureFlag === 'enablePayments') return featureFlags[item.featureFlag] === true; + return featureFlags[item.featureFlag] !== false; + }) + .sort((a, b) => a.order - b.order) + .map(item => ( + + + + )); + })()} + {/* Canvass button — always tied to enableMap, not in navConfig */} {settings?.enableMap !== false && ( - <> - - - - - - - - - - - )} - {settings?.enableMediaFeatures !== false && ( - + )} - {settings?.enablePayments && ( - <> - - - - - - - - - - - )}
`; } + case 'photo': { + const photoId = defaults.photoId || 'PLACEHOLDER'; + const size = defaults.size || 'large'; + const caption = (defaults.caption as string) || ''; + const linkToGallery = defaults.linkToGallery !== false; + const alignment = (defaults.alignment as string) || 'center'; + const maxWidth = (defaults.maxWidth as string) || '100%'; + + return ` +
+
+
+
+ + + + + +

Photo

+

ID: ${photoId}

+

Size: ${size}

+

Photo will render on published page

+
+
+
+
`; + } + case 'photo-card': { + const photoId = defaults.photoId; + const title = (defaults.title as string) || 'Photo Title'; + const description = (defaults.description as string) || ''; + const showMetadata = defaults.showMetadata !== false; + const mediaApiUrl = 'http://localhost:4100'; + + if (!photoId || photoId === 'PLACEHOLDER') { + return ` +
+
+
+
+ +

Photo Card

+

Select a photo to display

+
+
+
+
Photo Title
+
Card will render on published page
+
+
+
`; + } + + const cardHtml = generatePhotoCardHtml({ + id: photoId as number, + title, + description, + showMetadata, + viewCount: 0, + thumbnailUrl: `${mediaApiUrl}/api/public/photos/${photoId}/thumbnail`, + }); + + return `
${cardHtml}
`; + } + case 'photo-album': { + const albumId = defaults.albumId || 'PLACEHOLDER'; + const columns = defaults.columns || '3'; + const maxPhotos = defaults.maxPhotos || 12; + const showTitle = defaults.showTitle !== false; + + return ` +
+
+
+
+ + + + + +

Photo Album

+

Album ID: ${albumId} | ${columns} columns | Max ${maxPhotos} photos

+
+
+ ${Array.from({ length: Math.min(Number(columns) * 2, 6) }, () => ` +
+ +
`).join('')} +
+

Album will render on published page

+
+
+
`; + } + case 'ad-specific': { + const adId = defaults.adId || 0; + return ` +
+
+
+
📢
+

Specific Ad

+

ID: ${adId || 'Not set'}

+

Ad will render on published page

+
+
+
`; + } + case 'ad-slot': { + const variant = (defaults.variant as string) || 'standard'; + return ` +
+
+
+
🔀
+

Ad Slot (Dynamic)

+

Variant: ${variant}

+

Rotating ad will render on published page

+
+
+
`; + } default: return `

Custom block: ${type}

`; } diff --git a/admin/src/components/MediaPublicLayout.tsx b/admin/src/components/MediaPublicLayout.tsx index 4711e3fe..e8e075a4 100644 --- a/admin/src/components/MediaPublicLayout.tsx +++ b/admin/src/components/MediaPublicLayout.tsx @@ -9,6 +9,7 @@ import ChatBar from '@/components/media/chatbar/ChatBar'; import { useChatNotifications } from '@/hooks/useChatNotifications'; import { useSettingsStore } from '@/stores/settings.store'; import { hexToRgba } from '@/utils/color'; +import PublicNavBar from '@/components/PublicNavBar'; const { useBreakpoint } = Grid; @@ -79,6 +80,8 @@ export default function MediaPublicLayout() { > + + {/* Desktop: Show sidebar, Mobile: Hide */} {!isMobile && } @@ -86,7 +89,7 @@ export default function MediaPublicLayout() {
{ e.currentTarget.style.color = '#fff'; }} - onMouseLeave={(e) => { if (!active) e.currentTarget.style.color = 'rgba(255, 255, 255, 0.85)'; }} - > - {icon} - {label} - - ); -} - -function NavExternalLink({ href, icon, label }: { href: string; icon: React.ReactNode; label: string }) { - return ( - { e.currentTarget.style.color = '#fff'; }} - onMouseLeave={(e) => { e.currentTarget.style.color = 'rgba(255, 255, 255, 0.85)'; }} - > - {icon} - {label} - - ); -} - -function NavButton({ onClick, icon, label }: { onClick: () => void; icon: React.ReactNode; label: string }) { - return ( - { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick(); } }} - style={navItemStyle} - onMouseEnter={(e) => { e.currentTarget.style.color = '#fff'; }} - onMouseLeave={(e) => { e.currentTarget.style.color = 'rgba(255, 255, 255, 0.85)'; }} - > - {icon} - {label} - - ); -} +const { Content, Footer } = Layout; export default function PublicLayout() { const { settings } = useSettingsStore(); - const { isAuthenticated, logout, user } = useAuthStore(); - const isAdmin = user?.role === 'SUPER_ADMIN' || user?.role === 'INFLUENCE_ADMIN' || user?.role === 'MAP_ADMIN'; const navigate = useNavigate(); - const location = useLocation(); const [authModalOpen, setAuthModalOpen] = useState(false); const [authModalContext, setAuthModalContext] = useState<'generic' | 'campaign'>('generic'); - const [drawerOpen, setDrawerOpen] = useState(false); - const screens = Grid.useBreakpoint(); - const isMobile = !screens.md; - - // Donate/payment pages show minimal nav (Home + Logout only) - const isDonateSection = location.pathname.startsWith('/donate') || location.pathname.startsWith('/payment'); - - // Active route detection for nav highlight - const activeRoute = (() => { - const p = location.pathname; - if (p.startsWith('/campaign')) return '/campaigns'; - if (p.startsWith('/map')) return '/map'; - if (p.startsWith('/shifts') || p.startsWith('/volunteer')) return '/shifts'; - if (p.startsWith('/gallery')) return '/gallery'; - if (p.startsWith('/donate') || p.startsWith('/pricing') || p.startsWith('/shop')) return '/donate'; - return ''; - })(); const colorPrimary = settings?.publicColorPrimary ?? '#3498db'; const colorBgBase = settings?.publicColorBgBase ?? '#0d1b2a'; const colorBgContainer = settings?.publicColorBgContainer ?? '#1b2838'; - const headerGradient = settings?.publicHeaderGradient ?? 'linear-gradient(135deg, #005a9c 0%, #007acc 100%)'; - const orgName = settings?.organizationName ?? 'Changemaker Lite'; 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}`; + // Build footer links from navConfig (or defaults) + const footerLinks = useMemo(() => { + const items = settings?.navConfig?.items; + if (!items) { + // Legacy fallback: hardcoded links + const links: { label: string; path: string; external?: boolean }[] = []; + if (settings?.enableInfluence !== false) links.push({ label: 'Campaigns', path: '/campaigns' }); + if (settings?.enableMap !== false) { + links.push({ label: 'Map', path: '/map' }); + links.push({ label: 'Shifts', path: '/shifts' }); + } + if (settings?.enableMediaFeatures !== false) links.push({ label: 'Gallery', path: '/gallery' }); + if (settings?.enablePayments) links.push({ label: 'Donate', path: '/donate' }); + return links; } - return `http://localhost:8092`; - })(); + + const featureFlags: Record = { + enableInfluence: settings?.enableInfluence, + enableMap: settings?.enableMap, + enableMediaFeatures: settings?.enableMediaFeatures, + enablePayments: settings?.enablePayments, + enableEvents: settings?.enableEvents, + }; + + return items + .filter(item => item.enabled && item.id !== 'home') // Skip home in footer + .filter(item => { + if (!item.featureFlag) return true; + if (item.featureFlag === 'enablePayments') return featureFlags[item.featureFlag] === true; + return featureFlags[item.featureFlag] !== false; + }) + .sort((a, b) => a.order - b.order) + .map(item => ({ label: item.label, path: item.path, external: item.external })); + }, [settings]); // Dynamic document title + favicon for public pages useEffect(() => { @@ -154,82 +86,10 @@ export default function PublicLayout() { }} > -
- {/* Left: Logo */} - - {logoUrl && ( - {orgName} - )} - - {orgName} - - - - {/* Right: Navigation */} - {isMobile ? ( -
+ { setAuthModalContext('generic'); setAuthModalOpen(true); }} + /> +
{footerText}
- {settings?.enableInfluence !== false && ( - Campaigns - )} - {settings?.enableMap !== false && ( - <> - {' • '} - Map - {' • '} - Shifts - - )} - {settings?.enableEvents !== false && ( - <> - {' • '} - Events - - )} - {settings?.enableMediaFeatures !== false && ( - <> - {' • '} - Gallery - - )} - {settings?.enablePayments && ( - <> - {' • '} - Donate - - )} + {footerLinks.map((link, idx) => ( + + {idx > 0 && ' \u2022 '} + {link.external ? ( + + {link.label} + + ) : ( + + {link.label} + + )} + + ))}
- {/* Mobile Navigation Drawer */} - setDrawerOpen(false)} - open={drawerOpen} - width={280} - closeIcon={} - styles={{ - header: { background: colorBgContainer, borderBottom: '1px solid rgba(255,255,255,0.1)' }, - body: { background: colorBgBase, padding: '16px 0' }, - }} - > -
- 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, - }} - > - - Home - -
- {[ - { to: '/campaigns', icon: , label: 'Campaigns', show: settings?.enableInfluence !== false }, - { to: '/map', icon: , label: 'Map', show: settings?.enableMap !== false }, - { to: '/shifts', icon: , label: 'Shifts', show: settings?.enableMap !== false }, - { to: '/gallery', icon: , label: 'Gallery', show: settings?.enableMediaFeatures !== false }, - { to: '/donate', icon: , label: 'Donate', show: !!settings?.enablePayments }, - ].filter(item => item.show).map(item => ( - setDrawerOpen(false)} - style={{ - display: 'flex', alignItems: 'center', gap: 10, - padding: '12px 24px', - color: activeRoute === item.to ? '#fff' : 'rgba(255,255,255,0.85)', - textDecoration: 'none', fontSize: 15, - fontWeight: activeRoute === item.to ? 600 : 400, - background: activeRoute === item.to ? 'rgba(255,255,255,0.1)' : 'transparent', - borderRadius: 4, - }} - > - {item.icon} - {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 ? ( - <> - 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, - }} - > - {isAdmin ? : } - {isAdmin ? 'Admin Panel' : 'Volunteer Portal'} - - { logout(); setDrawerOpen(false); }} - onKeyDown={(e) => { if (e.key === 'Enter') { logout(); setDrawerOpen(false); } }} - style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '12px 24px', color: 'rgba(255,255,255,0.85)', cursor: 'pointer', fontSize: 15, background: 'none', border: 'none', font: 'inherit' }} - > - Logout - - - ) : ( - { setAuthModalContext('generic'); setAuthModalOpen(true); setDrawerOpen(false); }} - onKeyDown={(e) => { if (e.key === 'Enter') { setAuthModalContext('generic'); setAuthModalOpen(true); setDrawerOpen(false); } }} - style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '12px 24px', color: 'rgba(255,255,255,0.85)', cursor: 'pointer', fontSize: 15, background: 'none', border: 'none', font: 'inherit' }} - > - Sign In - - )} -
- - setAuthModalOpen(false)} diff --git a/admin/src/components/PublicNavBar.tsx b/admin/src/components/PublicNavBar.tsx new file mode 100644 index 00000000..ee8fc528 --- /dev/null +++ b/admin/src/components/PublicNavBar.tsx @@ -0,0 +1,511 @@ +import { useState, useEffect, useMemo } from 'react'; +import { Link, useLocation, useNavigate } from 'react-router-dom'; +import { Typography, Space, Grid, Drawer, Button, Tooltip, message } from 'antd'; +import { + HomeOutlined, + SendOutlined, + EnvironmentOutlined, + CalendarOutlined, + ScheduleOutlined, + PlayCircleOutlined, + HeartOutlined, + DollarOutlined, + ShoppingOutlined, + MenuOutlined, + CloseOutlined, + LoginOutlined, + LogoutOutlined, + AppstoreOutlined, + TeamOutlined, + LinkOutlined, + MenuFoldOutlined, + MenuUnfoldOutlined, + SearchOutlined, + UserOutlined, + GlobalOutlined, + BookOutlined, +} from '@ant-design/icons'; +import { useSettingsStore } from '@/stores/settings.store'; +import { useAuthStore } from '@/stores/auth.store'; +import { useLocalStorage } from '@/hooks/useLocalStorage'; +import PublicSearchModal from '@/components/PublicSearchModal'; +import { api } from '@/lib/api'; +import { resolveNavUrl } from '@/lib/service-url'; +import type { NavItem } from '@/types/api'; + +// Map icon string IDs to Ant Design icon components +const ICON_MAP: Record = { + HomeOutlined: , + SendOutlined: , + EnvironmentOutlined: , + CalendarOutlined: , + ScheduleOutlined: , + PlayCircleOutlined: , + HeartOutlined: , + DollarOutlined: , + ShoppingOutlined: , + LinkOutlined: , + GlobalOutlined: , + BookOutlined: , +}; + +/** Default nav items used when navConfig is null (matches plan's builtin items) */ +const DEFAULT_NAV_ITEMS: NavItem[] = [ + { id: 'home', label: 'Home', path: '/home', icon: 'HomeOutlined', enabled: true, order: 0, type: 'builtin' }, + { id: 'campaigns', label: 'Campaigns', path: '/campaigns', icon: 'SendOutlined', enabled: true, order: 1, type: 'builtin', featureFlag: 'enableInfluence' }, + { id: 'map', label: 'Map', path: '/map', icon: 'EnvironmentOutlined', enabled: true, order: 2, type: 'builtin', featureFlag: 'enableMap' }, + { id: 'shifts', label: 'Shifts', path: '/shifts', icon: 'ScheduleOutlined', enabled: true, order: 3, type: 'builtin', featureFlag: 'enableMap' }, + { id: 'events', label: 'Calendar', path: '/events', icon: 'CalendarOutlined', enabled: true, order: 4, type: 'builtin', featureFlag: 'enableEvents' }, + { id: 'gallery', label: 'Gallery', path: '/gallery', icon: 'PlayCircleOutlined', enabled: true, order: 5, type: 'builtin', featureFlag: 'enableMediaFeatures' }, + { id: 'pricing', label: 'Pricing', path: '/pricing', icon: 'DollarOutlined', enabled: true, order: 6, type: 'builtin', featureFlag: 'enablePayments' }, + { id: 'shop', label: 'Shop', path: '/shop', icon: 'ShoppingOutlined', enabled: true, order: 7, type: 'builtin', featureFlag: 'enablePayments' }, + { id: 'donate', label: 'Donate', path: '/donate', icon: 'HeartOutlined', enabled: true, order: 8, type: 'builtin', featureFlag: 'enablePayments' }, + { id: 'landing', label: 'Website', path: '$landing', icon: 'GlobalOutlined', enabled: false, order: 9, type: 'builtin', external: true }, + { id: 'docs', label: 'Docs', path: '$docs', icon: 'BookOutlined', enabled: false, order: 10, type: 'builtin', external: true }, +]; + +const navItemStyle: React.CSSProperties = { + color: 'rgba(255, 255, 255, 0.85)', + textDecoration: 'none', + display: 'flex', + alignItems: 'center', + gap: 6, + fontSize: 14, + transition: 'color 0.2s', + whiteSpace: 'nowrap', + cursor: 'pointer', + background: 'none', + border: 'none', + padding: 0, + font: 'inherit', +}; + +/** Resolve external URLs for builtin items (including $token paths) */ +function resolveItemUrl(item: NavItem): string { + if (item.path.startsWith('$')) return resolveNavUrl(item.path); + if (item.external && item.path.startsWith('http')) return item.path; + return item.path; +} + +/** Merge missing builtin defaults into stored navConfig items and sync icons */ +function mergeNavDefaults(stored: NavItem[]): NavItem[] { + const defaultMap = new Map(DEFAULT_NAV_ITEMS.filter(d => d.type === 'builtin').map(d => [d.id, d])); + // Sync icon for existing builtin items so code-level icon changes propagate + const synced = stored.map(item => { + const def = defaultMap.get(item.id); + return (def && item.type === 'builtin') ? { ...item, icon: def.icon } : item; + }); + const ids = new Set(synced.map(i => i.id)); + const missing = DEFAULT_NAV_ITEMS.filter(d => d.type === 'builtin' && !ids.has(d.id)); + return missing.length > 0 ? [...synced, ...missing] : synced; +} + +interface PublicNavBarProps { + activePath?: string; + showAuth?: boolean; + onSignInClick?: () => void; +} + +export default function PublicNavBar({ activePath, showAuth = true, onSignInClick }: PublicNavBarProps) { + const { settings } = useSettingsStore(); + const { isAuthenticated, logout, user } = useAuthStore(); + const isAdmin = user?.role === 'SUPER_ADMIN' || user?.role === 'INFLUENCE_ADMIN' || user?.role === 'MAP_ADMIN'; + const location = useLocation(); + const navigate = useNavigate(); + const [drawerOpen, setDrawerOpen] = useState(false); + const [searchOpen, setSearchOpen] = useState(false); + const [navCollapsed, setNavCollapsed] = useLocalStorage('public_nav_collapsed', false); + const [profileLoading, setProfileLoading] = useState(false); + const handleSignIn = onSignInClick ?? (() => navigate('/login')); + + const handleMyProfile = async () => { + if (profileLoading) return; + setProfileLoading(true); + try { + const { data } = await api.get<{ token: string }>('/auth/me/profile-token'); + navigate(`/profile/${data.token}`); + } catch { + message.error('Unable to load profile'); + } finally { + setProfileLoading(false); + } + }; + const screens = Grid.useBreakpoint(); + const isMobile = !screens.md; + + // Global Ctrl+K / Cmd+K to open search + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault(); + setSearchOpen(true); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, []); + + /** Animated label that collapses to zero-width when navCollapsed is true */ + const NavLabel = ({ label }: { label: string }) => ( + + {label} + + ); + + const headerGradient = settings?.publicHeaderGradient ?? 'linear-gradient(135deg, #005a9c 0%, #007acc 100%)'; + const orgName = settings?.organizationName ?? 'Changemaker Lite'; + const logoUrl = settings?.organizationLogoUrl; + const colorBgBase = settings?.publicColorBgBase ?? '#0d1b2a'; + const colorBgContainer = settings?.publicColorBgContainer ?? '#1b2838'; + + // Determine active route for nav highlight + const currentActive = activePath ?? (() => { + const p = location.pathname; + if (p === '/home') return '/home'; + if (p.startsWith('/campaign')) return '/campaigns'; + if (p.startsWith('/map')) return '/map'; + if (p.startsWith('/shifts') || p.startsWith('/volunteer')) return '/shifts'; + if (p.startsWith('/events')) return '/events'; + if (p.startsWith('/gallery')) return '/gallery'; + if (p.startsWith('/donate')) return '/donate'; + if (p.startsWith('/pricing')) return '/pricing'; + if (p.startsWith('/shop')) return '/shop'; + return ''; + })(); + + // Feature flag map for filtering + const featureFlags: Record = useMemo(() => ({ + enableInfluence: settings?.enableInfluence, + enableMap: settings?.enableMap, + enableMediaFeatures: settings?.enableMediaFeatures, + enablePayments: settings?.enablePayments, + enableEvents: settings?.enableEvents, + }), [settings]); + + // Get filtered, sorted nav items + const navItems = useMemo(() => { + const items = mergeNavDefaults(settings?.navConfig?.items ?? DEFAULT_NAV_ITEMS); + return items + .filter(item => item.enabled) + .filter(item => { + if (!item.featureFlag) return true; + // For payments flag, enablePayments defaults to false (opt-in) + if (item.featureFlag === 'enablePayments') return featureFlags[item.featureFlag] === true; + // Other flags default to true + return featureFlags[item.featureFlag] !== false; + }) + .sort((a, b) => a.order - b.order); + }, [settings?.navConfig, featureFlags]); + + const renderDesktopLink = (item: NavItem) => { + const isActive = currentActive === item.path; + const icon = ICON_MAP[item.icon] ?? null; + const linkStyle: React.CSSProperties = { + ...navItemStyle, + gap: navCollapsed ? 0 : 6, + color: isActive ? '#fff' : 'rgba(255, 255, 255, 0.85)', + fontWeight: isActive ? 600 : undefined, + borderBottom: isActive ? '2px solid #fff' : '2px solid transparent', + paddingBottom: 2, + }; + + if (item.external) { + return ( + + { e.currentTarget.style.color = '#fff'; }} + onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.color = 'rgba(255, 255, 255, 0.85)'; }} + > + {icon} + + + + ); + } + + return ( + + { e.currentTarget.style.color = '#fff'; }} + onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.color = 'rgba(255, 255, 255, 0.85)'; }} + > + {icon} + + + + ); + }; + + const renderMobileLink = (item: NavItem) => { + const isActive = currentActive === item.path; + const icon = ICON_MAP[item.icon] ?? null; + const style: React.CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: 10, + padding: '12px 24px', + color: isActive ? '#fff' : 'rgba(255,255,255,0.85)', + textDecoration: 'none', + fontSize: 15, + fontWeight: isActive ? 600 : 400, + background: isActive ? 'rgba(255,255,255,0.1)' : 'transparent', + borderRadius: 4, + }; + + if (item.external) { + return ( + setDrawerOpen(false)} + style={style} + > + {icon} + {item.label} + + ); + } + + return ( + setDrawerOpen(false)} + style={style} + > + {icon} + {item.label} + + ); + }; + + return ( + <> +
+ {/* Left: Logo + Brand */} + + {logoUrl && ( + {orgName} + )} + + {orgName} + + + + {/* Right: Navigation */} + {isMobile ? ( +
+ + {/* Mobile Navigation Drawer */} + setDrawerOpen(false)} + open={drawerOpen} + width={280} + closeIcon={} + styles={{ + header: { background: colorBgContainer, borderBottom: '1px solid rgba(255,255,255,0.1)' }, + body: { background: colorBgBase, padding: '16px 0' }, + }} + > +
+ {navItems.map(renderMobileLink)} +
+ {/* Auth buttons: always show Admin/Logout when logged in; show Sign In when not */} + {isAuthenticated ? ( + <> + { handleMyProfile(); setDrawerOpen(false); }} + onKeyDown={(e) => { if (e.key === 'Enter') { handleMyProfile(); setDrawerOpen(false); } }} + style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '12px 24px', color: 'rgba(255,255,255,0.85)', cursor: 'pointer', fontSize: 15, background: 'none', border: 'none', font: 'inherit', opacity: profileLoading ? 0.5 : 1 }} + > + My Profile + + 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, + }} + > + {isAdmin ? : } + {isAdmin ? 'Admin Panel' : 'Volunteer Portal'} + + { logout(); setDrawerOpen(false); }} + onKeyDown={(e) => { if (e.key === 'Enter') { logout(); setDrawerOpen(false); } }} + style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '12px 24px', color: 'rgba(255,255,255,0.85)', cursor: 'pointer', fontSize: 15, background: 'none', border: 'none', font: 'inherit' }} + > + Logout + + + ) : showAuth && ( + { handleSignIn(); setDrawerOpen(false); }} + onKeyDown={(e) => { if (e.key === 'Enter') { handleSignIn(); setDrawerOpen(false); } }} + style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '12px 24px', color: 'rgba(255,255,255,0.85)', cursor: 'pointer', fontSize: 15, background: 'none', border: 'none', font: 'inherit' }} + > + Sign In + + )} +
+ + + setSearchOpen(false)} /> + + ); +} diff --git a/admin/src/components/PublicSearchModal.tsx b/admin/src/components/PublicSearchModal.tsx new file mode 100644 index 00000000..04e8f3cf --- /dev/null +++ b/admin/src/components/PublicSearchModal.tsx @@ -0,0 +1,208 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Modal, Input, Typography, Tag, Empty, Spin, Grid } from 'antd'; +import { + SearchOutlined, + SendOutlined, + CalendarOutlined, + ScheduleOutlined, + FileTextOutlined, + PlayCircleOutlined, +} from '@ant-design/icons'; +import axios from 'axios'; + +const { Text } = Typography; + +interface SearchResult { + type: 'campaign' | 'shift' | 'page' | 'video' | 'event'; + id: string; + title: string; + description: string | null; + link: string; +} + +const TYPE_ICONS: Record = { + campaign: , + shift: , + page: , + video: , + event: , +}; + +const TYPE_COLORS: Record = { + campaign: 'blue', + shift: 'green', + page: 'purple', + video: 'magenta', + event: 'orange', +}; + +interface PublicSearchModalProps { + open: boolean; + onClose: () => void; +} + +export default function PublicSearchModal({ open, onClose }: PublicSearchModalProps) { + const navigate = useNavigate(); + const screens = Grid.useBreakpoint(); + const isMobile = !screens.md; + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [activeIndex, setActiveIndex] = useState(0); + const timerRef = useRef>(undefined); + const inputRef = useRef(null); + + // Focus input on open + useEffect(() => { + if (open) { + setQuery(''); + setResults([]); + setActiveIndex(0); + setTimeout(() => inputRef.current?.focus(), 100); + } + }, [open]); + + // Debounced search + useEffect(() => { + clearTimeout(timerRef.current); + if (!query || query.length < 2) { + setResults([]); + return; + } + timerRef.current = setTimeout(async () => { + setLoading(true); + try { + const { data } = await axios.get('/api/search', { + params: { q: query, limit: 10 }, + }); + setResults(data); + setActiveIndex(0); + } catch { + setResults([]); + } finally { + setLoading(false); + } + }, 300); + return () => clearTimeout(timerRef.current); + }, [query]); + + const handleSelect = useCallback((result: SearchResult) => { + onClose(); + navigate(result.link); + }, [navigate, onClose]); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setActiveIndex(i => Math.min(i + 1, results.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setActiveIndex(i => Math.max(i - 1, 0)); + } else if (e.key === 'Enter' && results[activeIndex]) { + e.preventDefault(); + handleSelect(results[activeIndex]); + } + }; + + // Global Ctrl+K handler + useEffect(() => { + // Only open from outside — parent manages the `open` state + }, []); + + // Group results by type + const grouped = results.reduce((acc, r) => { + if (!acc[r.type]) acc[r.type] = []; + acc[r.type]!.push(r); + return acc; + }, {} as Record); + + let flatIndex = 0; + + return ( + +
+ } + placeholder="Search campaigns, shifts, pages..." + value={query} + onChange={(e) => setQuery(e.target.value)} + onKeyDown={handleKeyDown} + variant="borderless" + size="large" + suffix={loading ? : ESC} + /> +
+ +
+ {query.length >= 2 && !loading && results.length === 0 && ( + No results found} + style={{ padding: 32 }} + image={Empty.PRESENTED_IMAGE_SIMPLE} + /> + )} + {Object.entries(grouped).map(([type, items]) => ( +
+
+ {type}s +
+ {items.map((item) => { + const idx = flatIndex++; + return ( +
handleSelect(item)} + onMouseEnter={() => setActiveIndex(idx)} + style={{ + display: 'flex', + alignItems: 'center', + gap: 10, + padding: '10px 16px', + cursor: 'pointer', + background: idx === activeIndex ? 'rgba(255,255,255,0.06)' : 'transparent', + transition: 'background 0.1s', + }} + > + + {TYPE_ICONS[item.type]} + +
+
+ {item.title} +
+ {item.description && ( + + {item.description} + + )} +
+ + {item.type} + +
+ ); + })} +
+ ))} +
+ + {query.length < 2 && ( +
+ + Type at least 2 characters to search + +
+ )} +
+ ); +} diff --git a/admin/src/components/VolunteerFooterNav.tsx b/admin/src/components/VolunteerFooterNav.tsx index dba58359..e6724834 100644 --- a/admin/src/components/VolunteerFooterNav.tsx +++ b/admin/src/components/VolunteerFooterNav.tsx @@ -3,17 +3,18 @@ import { useNavigate, useLocation } from 'react-router-dom'; import { theme } from 'antd'; import { EnvironmentOutlined, - CalendarOutlined, + ScheduleOutlined, HistoryOutlined, NodeIndexOutlined, MessageOutlined, + TeamOutlined, MenuOutlined, } from '@ant-design/icons'; import { useSettingsStore } from '@/stores/settings.store'; const BASE_NAV_ITEMS = [ { key: '/volunteer', icon: EnvironmentOutlined, label: 'Map' }, - { key: '/volunteer/shifts', icon: CalendarOutlined, label: 'Shifts' }, + { key: '/volunteer/shifts', icon: ScheduleOutlined, label: 'Shifts' }, { key: '/volunteer/activity', icon: HistoryOutlined, label: 'Activity' }, { key: '/volunteer/routes', icon: NodeIndexOutlined, label: 'Routes' }, ]; @@ -32,11 +33,14 @@ export default function VolunteerFooterNav({ style, onMenuOpen, menuActive = fal const NAV_ITEMS = useMemo(() => { const items = [...BASE_NAV_ITEMS]; + if (settings?.enableSocial) { + items.push({ key: '/volunteer/feed', icon: TeamOutlined, label: 'Social' }); + } if (settings?.enableChat) { items.push({ key: '/volunteer/chat', icon: MessageOutlined, label: 'Chat' }); } return items; - }, [settings?.enableChat]); + }, [settings?.enableChat, settings?.enableSocial]); const activeKey = (() => { const path = location.pathname; diff --git a/admin/src/components/VolunteerLayout.tsx b/admin/src/components/VolunteerLayout.tsx index f9934ee8..a13cf354 100644 --- a/admin/src/components/VolunteerLayout.tsx +++ b/admin/src/components/VolunteerLayout.tsx @@ -5,6 +5,9 @@ import type { MenuProps } from 'antd'; import { useAuthStore } from '@/stores/auth.store'; import { useSettingsStore } from '@/stores/settings.store'; import VolunteerFooterNav from '@/components/VolunteerFooterNav'; +import NotificationBell from '@/components/social/NotificationBell'; +import { buildHomeUrl } from '@/lib/service-url'; +import { useSSE } from '@/hooks/useSSE'; const { Header, Content, Footer } = Layout; @@ -13,6 +16,9 @@ export default function VolunteerLayout() { const { user, logout } = useAuthStore(); const { settings } = useSettingsStore(); + // Initialize SSE connection for real-time notifications + online presence + useSSE(); + const orgName = settings?.organizationName ?? 'Changemaker Lite'; const handleLogout = async () => { @@ -21,7 +27,7 @@ export default function VolunteerLayout() { }; const userMenuItems: MenuProps['items'] = [ - { key: 'home', icon: , label: 'Home', onClick: () => window.open(`//${window.location.hostname}:4004`, '_blank') }, + { key: 'home', icon: , label: 'Home', onClick: () => window.open(buildHomeUrl(), '_blank') }, { key: 'browse', icon: , label: 'Browse Site', onClick: () => navigate('/campaigns') }, { type: 'divider' }, { key: 'logout', icon: , label: 'Logout', onClick: handleLogout }, @@ -56,6 +62,7 @@ export default function VolunteerLayout() { {orgName} + {settings?.enableSocial && } + )} + +
+ } + /> + + ); + } + + const defaultDate = initialDate ? dayjs(initialDate) : dayjs().add(1, 'day'); + + return ( + + + Submit a Community Event + + } + style={{ borderLeft: '4px solid #52c41a', marginBottom: 8 }} + > +
+ + + + + + ' + + '
' + + '' + + '
' + + '
' + ); + } + + // Anonymous form with optional OAuth login + var loginBtn = ''; + if (oauthConfig && oauthConfig.oauthEnabled) { + loginBtn = ''; + } + + return ( + '
' + + '
' + + '' + + '' + + '
' + + // Honeypot — hidden from humans + '' + + '' + + '
' + + '' + + loginBtn + + '
' + + '

Guest comments are reviewed before appearing.

' + + '
' + ); + } + + // --- Main Init --- + + function initWidget(container) { + var pagePath = container.getAttribute('data-page-path') || ''; + if (!pagePath) return; + + API_BASE = getApiUrl(); + + container.innerHTML = + '
' + + '

Comments

' + + '
Loading comments...
' + + '
' + + '
'; + + var listEl = container.querySelector('.dc-comments-list'); + var formContainer = container.querySelector('.dc-form-container'); + var oauthConfig = null; + + // Load OAuth config + comments in parallel + fetchOAuthConfig(function (err, config) { + if (!err && config) oauthConfig = config; + renderFormSection(); + }); + + loadComments(1); + + function loadComments(page) { + listEl.classList.add('dc-loading'); + listEl.innerHTML = 'Loading comments...'; + + fetchComments(pagePath, page, function (err, data) { + listEl.classList.remove('dc-loading'); + if (err) { + listEl.innerHTML = '

Comments unavailable.

'; + return; + } + listEl.innerHTML = renderCommentList(data); + }); + } + + function renderFormSection() { + formContainer.innerHTML = renderForm(pagePath, oauthConfig); + bindFormEvents(); + } + + function bindFormEvents() { + // Anonymous submit + var submitAnon = formContainer.querySelector('.dc-submit-anon'); + if (submitAnon) { + submitAnon.addEventListener('click', function () { + var name = formContainer.querySelector('.dc-anon-name').value.trim(); + var email = formContainer.querySelector('.dc-anon-email').value.trim(); + var body = formContainer.querySelector('.dc-anon-body').value.trim(); + var honeypot = formContainer.querySelector('.dc-honeypot').value; + + if (!name) { showFormError('Please enter your name.'); return; } + if (body.length < 10) { showFormError('Comment must be at least 10 characters.'); return; } + + submitAnon.disabled = true; + submitAnon.textContent = 'Posting...'; + + postAnonymous( + { pagePath: pagePath, authorName: name, authorEmail: email || undefined, body: body, website: honeypot }, + function (err) { + submitAnon.disabled = false; + submitAnon.textContent = 'Post as Guest'; + if (err) { showFormError(err.message || 'Failed to post comment.'); return; } + formContainer.querySelector('.dc-anon-body').value = ''; + showFormSuccess('Your comment has been submitted for review.'); + } + ); + }); + } + + // Authenticated submit + var submitAuth = formContainer.querySelector('.dc-submit-auth'); + if (submitAuth) { + submitAuth.addEventListener('click', function () { + var body = formContainer.querySelector('.dc-auth-body').value.trim(); + var token = getToken(); + + if (body.length < 10) { showFormError('Comment must be at least 10 characters.'); return; } + if (!token) { showFormError('Session expired. Please sign in again.'); clearAuth(); renderFormSection(); return; } + + submitAuth.disabled = true; + submitAuth.textContent = 'Posting...'; + + postAuthenticated( + { pagePath: pagePath, body: body }, + token, + function (err) { + submitAuth.disabled = false; + submitAuth.textContent = 'Post Comment'; + if (err) { + if (err.message && err.message.indexOf('401') !== -1) { + clearAuth(); + renderFormSection(); + showFormError('Session expired. Please sign in again.'); + return; + } + showFormError(err.message || 'Failed to post comment.'); + return; + } + formContainer.querySelector('.dc-auth-body').value = ''; + loadComments(1); + } + ); + }); + } + + // OAuth login + var loginBtn = formContainer.querySelector('.dc-login-btn'); + if (loginBtn && oauthConfig && oauthConfig.oauthEnabled) { + loginBtn.addEventListener('click', function () { + var state = Math.random().toString(36).slice(2); + try { + sessionStorage.setItem(STATE_KEY, state); + sessionStorage.setItem(RETURN_KEY, window.location.href); + } catch { /* ignore */ } + + var redirectUri = window.location.origin + '/comments/callback/'; + var url = oauthConfig.authorizeUrl + + '?client_id=' + encodeURIComponent(oauthConfig.clientId) + + '&redirect_uri=' + encodeURIComponent(redirectUri) + + '&response_type=code' + + '&state=' + encodeURIComponent(state); + + window.location.href = url; + }); + } + + // Logout + var logoutBtn = formContainer.querySelector('.dc-logout-btn'); + if (logoutBtn) { + logoutBtn.addEventListener('click', function () { + clearAuth(); + renderFormSection(); + }); + } + } + + function showFormError(msg) { + removeFormMessages(); + var el = document.createElement('p'); + el.className = 'dc-form-message dc-form-message--error'; + el.textContent = msg; + formContainer.appendChild(el); + setTimeout(function () { el.remove(); }, 5000); + } + + function showFormSuccess(msg) { + removeFormMessages(); + var el = document.createElement('p'); + el.className = 'dc-form-message dc-form-message--success'; + el.textContent = msg; + formContainer.appendChild(el); + setTimeout(function () { el.remove(); }, 5000); + } + + function removeFormMessages() { + var msgs = formContainer.querySelectorAll('.dc-form-message'); + for (var i = 0; i < msgs.length; i++) msgs[i].remove(); + } + } + + // --- OAuth Callback Handler --- + + function handleOAuthCallback() { + var params = new URLSearchParams(window.location.search); + var code = params.get('code'); + var state = params.get('state'); + + if (!code) return; + + // Verify state + var savedState; + try { savedState = sessionStorage.getItem(STATE_KEY); } catch { /* ignore */ } + if (savedState && state !== savedState) { + console.warn('[DocsComments] OAuth state mismatch'); + return; + } + + API_BASE = getApiUrl(); + var redirectUri = window.location.origin + '/comments/callback/'; + + exchangeCode(code, redirectUri, function (err, data) { + if (err || !data || !data.accessToken) { + console.error('[DocsComments] OAuth exchange failed:', err); + return; + } + + setAuth(data.accessToken, data.user); + + // Redirect back to original page + var returnUrl; + try { returnUrl = sessionStorage.getItem(RETURN_KEY); } catch { /* ignore */ } + try { + sessionStorage.removeItem(STATE_KEY); + sessionStorage.removeItem(RETURN_KEY); + } catch { /* ignore */ } + + window.location.href = returnUrl || '/'; + }); + } + + // --- SPA Init --- + + function init() { + // Check if this is the OAuth callback page + if (window.location.pathname.indexOf('/comments/callback') !== -1) { + handleOAuthCallback(); + return; + } + + var container = document.getElementById('docs-comments'); + if (container) { + initWidget(container); + } + } + + // MkDocs Material SPA navigation support + if (typeof document$ !== 'undefined') { + document$.subscribe(function () { + init(); + }); + } else { + // Fallback for non-SPA or non-Material builds + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } + } +})(); diff --git a/mkdocs/docs/overrides/lander.html b/mkdocs/docs/overrides/lander.html index 6420a6ba..7597a80b 100644 --- a/mkdocs/docs/overrides/lander.html +++ b/mkdocs/docs/overrides/lander.html @@ -40,6 +40,7 @@ --branch-devops: #FBBF24; --branch-sovereignty: #F87171; --branch-fundraising: #EC4899; + --branch-social: #38BDF8; /* Surfaces — dark */ --bg-deep: #0F172A; @@ -768,6 +769,130 @@ line-height: 1.6; } + /* ============================================ + GROWING CHANGE — Philosophy Bridge + ============================================ */ + .growing-change { + background: var(--bg-deep); + border-top: 1px solid var(--border-color); + border-bottom: 1px solid var(--border-color); + } + + .growing-change-content { + max-width: 840px; + margin: 0 auto; + text-align: center; + } + + .growing-change-card { + background: rgba(30, 41, 59, 0.85); + border: 1px solid rgba(148, 163, 184, 0.12); + border-radius: var(--radius-lg); + padding: 2.5rem 3rem; + margin-bottom: 3rem; + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + position: relative; + z-index: 2; + } + + [data-theme="light"] .growing-change-card { + background: rgba(255, 255, 255, 0.82); + border-color: rgba(100, 116, 139, 0.15); + } + + .growing-change-card h2 { + margin-bottom: 1.5rem; + background: linear-gradient(135deg, var(--primary-light) 0%, #C084FC 50%, var(--success) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + } + + .growing-lead { + font-size: 1.2rem; + color: var(--text-secondary); + line-height: 1.8; + margin-bottom: 1.25rem; + } + + .growing-change-card > p { + color: var(--text-secondary); + font-size: 1.05rem; + line-height: 1.7; + } + + .growing-change-card > p:last-child { + margin-bottom: 0; + } + + .growing-callout { + font-size: 0.95rem; + color: var(--text-muted); + font-style: italic; + border-left: 3px solid var(--primary-light); + padding-left: 1rem; + margin-top: 1.25rem; + } + + .growing-cta { + text-align: center; + margin-top: 2.5rem; + } + + .growing-pillars { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1.5rem; + text-align: left; + } + + .growing-pillar { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius); + padding: 1.75rem; + transition: all var(--transition); + border-top: 2px solid var(--success); + } + + .growing-pillar:nth-child(2) { + border-top-color: var(--primary-light); + } + + .growing-pillar:nth-child(3) { + border-top-color: var(--branch-content); + } + + .growing-pillar:hover { + transform: translateY(-3px); + box-shadow: var(--shadow-md); + border-color: var(--myc-node-border); + } + + .pillar-icon { + font-size: 1.75rem; + margin-bottom: 0.75rem; + } + + .growing-pillar h4 { + font-size: 1.05rem; + font-weight: 700; + margin-bottom: 0.5rem; + } + + .growing-pillar p { + color: var(--text-secondary); + font-size: 0.9rem; + line-height: 1.6; + } + + @media (max-width: 768px) { + .growing-pillars { + grid-template-columns: 1fr; + } + } + /* ============================================ FEATURE NETWORK — THE CORE VISUAL ============================================ */ @@ -889,6 +1014,7 @@ .branch-icon.devops { background: rgba(251, 191, 36, 0.15); border: 1px solid rgba(251, 191, 36, 0.3); } .branch-icon.sovereignty { background: rgba(248, 113, 113, 0.15); border: 1px solid rgba(248, 113, 113, 0.3); } .branch-icon.fundraising { background: rgba(236, 72, 153, 0.15); border: 1px solid rgba(236, 72, 153, 0.3); } + .branch-icon.social { background: rgba(56, 189, 248, 0.15); border: 1px solid rgba(56, 189, 248, 0.3); } .branch-title h3 { font-size: 1.35rem; @@ -938,6 +1064,7 @@ .branch-devops .feature-node { border-top: 2px solid var(--branch-devops); } .branch-sovereignty .feature-node { border-top: 2px solid var(--branch-sovereignty); } .branch-fundraising .feature-node { border-top: 2px solid var(--branch-fundraising); } + .branch-social .feature-node { border-top: 2px solid var(--branch-social); } .branch-comm .feature-node:hover { border-color: var(--branch-comm); box-shadow: 0 0 20px rgba(192,132,252,0.2); } .branch-map .feature-node:hover { border-color: var(--branch-map); box-shadow: 0 0 20px rgba(52,211,153,0.2); } @@ -946,6 +1073,7 @@ .branch-devops .feature-node:hover { border-color: var(--branch-devops); box-shadow: 0 0 20px rgba(251,191,36,0.2); } .branch-sovereignty .feature-node:hover { border-color: var(--branch-sovereignty); box-shadow: 0 0 20px rgba(248,113,113,0.2); } .branch-fundraising .feature-node:hover { border-color: var(--branch-fundraising); box-shadow: 0 0 20px rgba(236,72,153,0.2); } + .branch-social .feature-node:hover { border-color: var(--branch-social); box-shadow: 0 0 20px rgba(56,189,248,0.2); } /* Branch-specific node icon tinting */ .branch-comm .node-icon { background: rgba(192,132,252,0.12); } @@ -955,6 +1083,7 @@ .branch-devops .node-icon { background: rgba(251,191,36,0.12); } .branch-sovereignty .node-icon { background: rgba(248,113,113,0.12); } .branch-fundraising .node-icon { background: rgba(236,72,153,0.12); } + .branch-social .node-icon { background: rgba(56,189,248,0.12); } .node-header { display: flex; @@ -1447,7 +1576,6 @@ } .branch { padding-left: 0; } - .root-network-svg { display: none; } .floating-elements { display: none; } .sites-grid { @@ -1665,8 +1793,9 @@
Self-Hosted Campaign Infrastructure

Grow Power.
Don't Rent It.

- A deeply integrated and opinionated collection of free and open source services and production ready applications for growing political movements. Campaigns, canvassing, fundraising, team chat, and media — all on your own infrastructure. - No corporate surveillance. No foreign interference. No monthly ransoms. Free and open source. + Run your campaigns, canvassing, fundraising, team chat, media, and more — all on your own infrastructure. + No corporate surveillance. No foreign interference. No monthly ransoms. + A free and open source toolkit built for growing political movements.

Schedule a Chat @@ -1702,7 +1831,7 @@
Self-Hosted
-
30+
+
45+
Integrated Tools
@@ -1757,6 +1886,42 @@
+ +
+
+
+
+

Software Should Grow Power, Not Extract It

+

Most campaign and political software is extractive by nature — designed to pull information from a community in order to influence politics. Your voter data in corporate clouds. Your strategies readable by foreign jurisdictions. Your movement’s future in someone else’s hands.

+

Changemaker asks a different question: “what tools are needed to grow change in a community?” Growing change means making connections between people, providing access to tools that create new opportunities, and deeply understanding the wants and needs of your movement — on infrastructure you control.

+

Organizational independence requires technological independence. Socialist movements will never outspend capital — but a thousand neighborhood mailing lists has more potential impact than any single organization. Workers, with the right tools, will build the future.

+
+
+
+
🤝
+

Distribute Power

+

Decentralized organizing is the way out. When knowledge and tools are widely distributed — not gatekept by leadership or locked behind vendor paywalls — movements become resilient.

+
+
+
🔒
+

Own Your Secrets

+

If you do politics, who is reading your secrets? Corporate platforms extract intelligence systematically. Self-hosted infrastructure means your strategies stay yours — no algorithmic surveillance, no foreign data laws, no backdoors.

+
+
+
🌱
+

De-Corp Your Stack

+

Every subscription to corporate software funds the machine you’re fighting. Free and open source tools reduce dependence on capital, eliminate vendor lock-in, and keep your movement’s resources where they belong — in the community.

+
+
+ +
+
+
+ @@ -1764,7 +1929,7 @@

The Network

-

30+ tools connected — each node strengthens the whole

+

50+ tools connected — each node strengthens the whole

@@ -1773,7 +1938,7 @@
📨

Communication

-

Email campaigns, newsletters, advocacy, and public engagement

+

Email campaigns, SMS outreach, newsletters, advocacy, and team chat

@@ -1825,6 +1990,22 @@

Async notification queue for admin alerts and volunteer feedback. Shift reminders, session summaries, and signup confirmations.

BullMQRemindersSummaries
+
+
+
📱
+

SMS Campaigns

+
+

Text message outreach via Termux Android bridge. Contact lists, templated campaigns, delivery tracking, response sync, and device health monitoring.

+
Termux bridgeBullMQ queueResponse sync
+
+
+
+
🗨
+

Chat Widget

+
+

Floating Rocket.Chat panel for logged-in team members. Minimizable FAB, auth-gated access, and settings-toggleable visibility across the admin interface.

+
Rocket.ChatAuth-gatedFloating
+
@@ -1886,6 +2067,14 @@

Import Canadian National Address Register data with province/city/postal filtering, coordinate projection, and streaming.

2025 formatProj4Streaming
+
+
+
📆
+

Gancio Events

+
+

Public event calendar synced from shifts via Gancio. OAuth integration, map markers for upcoming events, and embeddable GrapesJS block.

+
OAuth syncMap markersEmbeddable
+
@@ -1895,7 +2084,7 @@
🎬

Content & Media

-

Video library, page builder, documentation, and web IDE

+

Video, photos, playlists, page builder, documentation, and web IDE

@@ -1904,8 +2093,8 @@
📹

Video Library

-

Upload, manage, and share videos with FFprobe metadata extraction, scheduled publishing, analytics, and a public gallery.

-
AnalyticsScheduling10GB uploads
+

Upload and manage videos with FFprobe metadata, scheduled publishing, view analytics, emoji reactions, threaded comments, and live chat.

+
AnalyticsLive chatScheduling
@@ -1920,8 +2109,8 @@
📖

MkDocs Documentation

-

Material-themed documentation site with full-text search, blog, social cards, and Jinja2 template overrides.

-
Material themeLunr searchBlog
+

Material-themed docs with full-text search, blog, social cards, and Gitea-backed page comments with anonymous posting and moderation.

+
Material themeCommentsBlog
@@ -1939,6 +2128,30 @@

Collaborative diagramming and whiteboard tool. Plan canvassing routes, sketch campaign strategies, and brainstorm as a team.

CollaborativeDiagramsReal-time
+
+
+
📷
+

Photo Management

+
+

Album organization with bulk uploads, metadata extraction, and engagement tracking. Reactions, comments, and a public photo gallery.

+
AlbumsEngagementGallery
+
+
+
+
🎵
+

Playlists

+
+

Curated video collections with admin, user, and public playlists. Drag-reorder, sidebar navigation, featured carousel, and dedicated viewer page.

+
CuratedPublic/PrivateReorderable
+
+
+
+
📲
+

Shorts Feed

+
+

TikTok-style vertical video feed for clips under 60 seconds. Autoplay, sorting modes, and mobile-optimized swipeable interface.

+
Vertical videoAutoplayMobile-first
+
@@ -1948,7 +2161,7 @@
📊

Data & Automation

-

Database browsing, workflow automation, version control, and utilities

+

Database browsing, workflow automation, version control, search, and utilities

@@ -1984,6 +2197,22 @@

QR code generator for walk sheets, campaign materials, and event signage. Instant PNG generation.

PNG outputEmbeddablePublic API
+
+
+
+

Command Palette

+
+

Global Ctrl+K search across pages, campaigns, locations, users, and settings. Fuzzy matching, recent items, and keyboard-driven navigation.

+
Ctrl+KFuzzy searchKeyboard-first
+
+
+
+
+

Navigation Settings

+
+

Customizable public nav menu with feature toggles, custom external links, drag-reorder, and real-time preview. Control what visitors see.

+
Drag-reorderFeature flagsCustom links
+
@@ -2037,6 +2266,14 @@

Self-hosted Bitwarden-compatible password manager. Secure credential sharing for your team with real-time sync and browser extensions.

BitwardenTeam sharingEncrypted
+
+
+
👥
+

User Provisioning

+
+

Automatic account sync across Rocket.Chat, Gitea, Vaultwarden, and Listmonk. Eager or lazy strategies with per-user status tracking and bulk sync.

+
4 servicesAuto-syncLifecycle hooks
+
@@ -2090,10 +2327,71 @@

Promote donations, products, and subscriptions within the media gallery. Visibility targeting, scheduling, and click analytics.

TargetingSchedulingCTR tracking
+
+
+
📝
+

Donation Pages

+
+

Custom branded donation pages with configurable amounts, thank-you messages, and public slugs. Multiple campaigns with independent branding and goals.

+
Custom brandingSlug URLsGoals
+
- + +
+
+ +
+

Social & Community

+

Friendships, activity feeds, achievements, groups, reactions, and real-time notifications

+
+
+
+
+
+
👥
+

Friend System

+
+

Friend requests, suggestions, pokes, cross-module badges on campaigns, shifts, and the map.

+
Friend requestsSuggestionsPoke
+
+
+
+
📰
+

Activity Feed

+
+

Real-time SSE feed of friend activity across campaigns, shifts, canvassing, and responses.

+
Real-timeSSECross-module
+
+
+
+
🏆
+

Achievements & Notifications

+
+

Milestone badges, real-time notification bell with friend requests, pokes, comments, and alerts.

+
BadgesBell UIReal-time
+
+
+
+
👫
+

Groups & Teams

+
+

Auto-groups for shift teams and campaign crews, custom groups, and shared updates.

+
Shift teamsCampaign crewsCustom groups
+
+
+
+
😍
+

Reactions & Comments

+
+

6 emoji reactions with floating animations, threaded comments with word-filter safety, pagination, and auto-notification.

+
6 emoji typesThreadedContent safety
+
+
+
+ +
🇨🇦
@@ -2238,7 +2536,7 @@
$0
forever
@@ -1181,62 +1459,24 @@ - - - - - - - - - - -
  • + - - - - - - - - - - - Feature Guides - - - - - - - - - - - - - - -
  • - - - - - - - - - - - - - + + + + + + + + + + + @@ -1264,7 +1504,7 @@ - Administration + Admin Guide @@ -1297,6 +1537,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
  • + + + + + + + + + + + + + User Guide + + + + + + + + + + + + + + +
  • + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1632,6 +1954,34 @@ + + + + + + +
  • + + + + + + + + Philosophy + + + + + + + + +
  • + + + + @@ -1834,6 +2184,17 @@
    + + +
  • + + + + Payments (Stripe) + + + +
  • @@ -1966,6 +2327,39 @@ +
  • + +
  • + + + + Vaultwarden (Password Manager) + + + + +
  • + +
  • + + + + Rocket.Chat (Team Chat) + + + + +
  • + +
  • + + + + Gancio (Event Management) + + + +
  • @@ -2452,6 +2846,24 @@ Then restart the API: docker compose restart api


    +

    Payments (Stripe)

    + + + + + + + + + + + + + + + +
    VariableDefaultDescription
    ENABLE_PAYMENTSfalse Set to true to enable the payments feature (memberships, products, donations). Stripe API keys are stored encrypted in the database via the admin settings page.
    +

    Email / SMTP

    @@ -2527,8 +2939,8 @@ Then restart the API: docker compose restart api

    - - + + @@ -2727,6 +3139,11 @@ Then restart the API: docker compose restart api

    + + + + + @@ -2897,7 +3314,7 @@ Then restart the API: docker compose restart api

    - + @@ -3035,6 +3452,152 @@ Then restart the API: docker compose restart api

    LISTMONK_DB_PORT5432Listmonk's own PostgreSQL port (separate from the main DB).5434Listmonk's own PostgreSQL port (separate from the main DB). Uses 5434 to avoid conflict with the main PostgreSQL (5432 internal / 5433 host).
    LISTMONK_DB_USER Maximum single-file upload size in gigabytes.
    PUBLIC_MEDIA_PORT3100Public media gallery server port.
    VIDEO_PLAYER_DEBUG false Enable verbose video player logging.
    MKDOCS_SITE_SERVER_PORT40014004 MkDocs static site server port.

    +

    Vaultwarden (Password Manager)

    +

    Self-hosted Bitwarden-compatible password manager. Optional service.

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    VariableDefaultDescription
    VAULTWARDEN_PORT8445Vaultwarden web UI port.
    VAULTWARDEN_URLhttp://vaultwarden-changemaker:80Internal container URL.
    VAULTWARDEN_EMBED_PORT8890Port for iframe embedding in admin.
    VAULTWARDEN_ADMIN_TOKEN(empty)Admin panel token (access at /admin). Generate with openssl rand -hex 32.
    VAULTWARDEN_DOMAINhttps://vault.cmlite.orgPublic-facing URL. Must use HTTPS — Bitwarden web vault enforces HTTPS for account creation. Set to your Pangolin tunnel URL.
    VAULTWARDEN_SIGNUPS_ALLOWEDfalseAllow new user self-registration. Keep false and use admin panel invites.
    VAULTWARDEN_WEBSOCKET_ENABLEDtrueEnable WebSocket notifications for real-time sync.
    VAULTWARDEN_SMTP_SECURITYoffSMTP security mode: off for MailHog, starttls or force_tls for production. Uses the main SMTP_* variables for host/credentials.
    +
    +

    Initial setup

    +

    The vaultwarden-init container automatically invites the INITIAL_ADMIN_EMAIL user when starting. Check MailHog (or your SMTP) for the invitation email.

    +
    +
    +

    Rocket.Chat (Team Chat)

    +

    Self-hosted team chat for volunteer coordination. Requires MongoDB (auto-configured).

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    VariableDefaultDescription
    ENABLE_CHATfalse Set to true to enable the Rocket.Chat integration. The initial default; once saved in admin Settings, the DB value is authoritative.
    ROCKETCHAT_ADMIN_USERrcadminRocket.Chat admin username.
    ROCKETCHAT_ADMIN_PASSWORD Rocket.Chat admin password.
    ROCKETCHAT_URLhttp://rocketchat-changemaker:3000Internal container URL.
    ROCKETCHAT_EMBED_PORT8891Port for iframe embedding in admin.
    +
    +

    Gancio (Event Management)

    +

    Self-hosted event management platform. Uses the shared PostgreSQL database (auto-created by init-gancio-db.sh).

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    VariableDefaultDescription
    GANCIO_PORT8092Gancio web UI port.
    GANCIO_URLhttp://gancio-changemaker:13120Internal container URL.
    GANCIO_EMBED_PORT8892Port for iframe embedding in admin.
    GANCIO_BASE_URLhttps://events.cmlite.orgPublic-facing URL for Gancio. Used in event links.
    GANCIO_ADMIN_USERadminGancio admin username for shift-to-event sync (OAuth login).
    GANCIO_ADMIN_PASSWORD Gancio admin password.
    GANCIO_SYNC_ENABLEDfalse Set to true to enable automatic shift → Gancio event synchronization.
    +

    MailHog (Development Email)

    @@ -3244,7 +3807,7 @@ Then restart the API: docker compose restart api

    - + @@ -3254,12 +3817,12 @@ Then restart the API: docker compose restart api

    - + - + @@ -3322,6 +3885,15 @@ Then restart the API: docker compose restart api

    echo "N8N_USER_PASSWORD=$(openssl rand -hex 16)" echo "NC_ADMIN_PASSWORD=$(openssl rand -hex 16)" echo "INITIAL_ADMIN_PASSWORD=$(openssl rand -base64 18)" + +# Vaultwarden +echo "VAULTWARDEN_ADMIN_TOKEN=$(openssl rand -hex 32)" + +# Rocket.Chat +echo "ROCKETCHAT_ADMIN_PASSWORD=$(openssl rand -hex 16)" + +# Gancio +echo "GANCIO_ADMIN_PASSWORD=$(openssl rand -hex 16)"

    Tip

    @@ -3347,20 +3919,26 @@ Then restart the API: docker compose restart api

    For the complete platform including media, newsletters, monitoring, and all services:

    Additional variables needed
    # Everything above, plus:
     ENABLE_MEDIA_FEATURES=true
    -LISTMONK_SYNC_ENABLED=true
    -LISTMONK_DB_PASSWORD=...
    -LISTMONK_WEB_ADMIN_PASSWORD=...
    -LISTMONK_API_TOKEN=...
    -NC_ADMIN_PASSWORD=...
    -GITEA_DB_PASSWD=...
    -GITEA_DB_ROOT_PASSWORD=...
    -N8N_ENCRYPTION_KEY=...
    -N8N_USER_PASSWORD=...
    -EMAIL_TEST_MODE=false
    -SMTP_HOST=smtp.your-provider.com
    -SMTP_PORT=587
    -SMTP_USER=you@example.com
    -SMTP_PASS=your-smtp-password
    +ENABLE_PAYMENTS=true
    +ENABLE_CHAT=true
    +LISTMONK_SYNC_ENABLED=true
    +GANCIO_SYNC_ENABLED=true
    +LISTMONK_DB_PASSWORD=...
    +LISTMONK_WEB_ADMIN_PASSWORD=...
    +LISTMONK_API_TOKEN=...
    +NC_ADMIN_PASSWORD=...
    +GITEA_DB_PASSWD=...
    +GITEA_DB_ROOT_PASSWORD=...
    +N8N_ENCRYPTION_KEY=...
    +N8N_USER_PASSWORD=...
    +VAULTWARDEN_ADMIN_TOKEN=...
    +ROCKETCHAT_ADMIN_PASSWORD=...
    +GANCIO_ADMIN_PASSWORD=...
    +EMAIL_TEST_MODE=false
    +SMTP_HOST=smtp.your-provider.com
    +SMTP_PORT=587
    +SMTP_USER=you@example.com
    +SMTP_PASS=your-smtp-password
     
    Start services
    docker compose up -d
     docker compose --profile monitoring up -d
    @@ -3408,7 +3986,7 @@ Then restart the API: docker compose restart api

    @@ -800,62 +1034,24 @@ - - - - - - - - - - -
  • + - - - - - - - - - - - Feature Guides - - - - - - - - - - - - - - -
  • - - - - - - - - - - - - - + + + + + + + + + + + @@ -883,7 +1079,7 @@ - Administration + Admin Guide @@ -916,6 +1112,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
  • + + + + + + + + + + + + + User Guide + + + + + + + + + + + + + + +
  • + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1251,6 +1529,34 @@ + + + + + + +
  • + + + + + + + + Philosophy + + + + + + + + +
  • + + + + @@ -1695,10 +2001,11 @@

    Next Steps

    @@ -1757,13 +2064,13 @@ - +
    GRAFANA_PORT30013005 Grafana dashboard port.
    GRAFANA_ROOT_URLhttp://localhost:3001http://localhost:3005 Public URL for Grafana (used in links).
    CADVISOR_PORT80808086 cAdvisor container metrics port.
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Corporate ToolChangemaker AlternativeWhat You Gain
    MailchimpListmonkUnlimited subscribers, no per-send charges, your data stays local
    NationBuilderChangemaker LiteFull campaign platform without the $50-500/month ransom
    Google DocsGitea + Code ServerVersion control, collaboration, no algorithmic scanning
    SlackRocket.ChatTeam chat with SSO, no message limits, no corporate eavesdropping
    SurveyMonkeyResponse WallSupporter voices on your terms, with moderation you control
    Google MapsSelf-hosted LeafletNo API fees, no tracking, offline-capable canvassing
    +

    The cost reduction is dramatic. Organizations spending thousands monthly on SaaS tools can replace them with a single self-hosted server running Changemaker Lite for roughly the cost of hosting — often under $50/month.

    +

    But the real value isn't cost savings. It's control. No vendor can cut off your access. No acquisition can change your terms. No government can compel a foreign company to hand over your data. Your movement's digital infrastructure belongs to your movement.

    +
    +

    Security Culture Starts With Infrastructure

    +

    Security culture isn't just about who knows what — it's about who can know what. When your communications run through corporate servers, you've made a structural decision about who has access before you've even thought about operational security.

    +

    Key principles:

    +
      +
    • Compartmentalization by design — Self-hosted systems let you control exactly who has access to what, at the infrastructure level
    • +
    • No third-party access — No corporate subpoenas for your data, no partnership agreements sharing your information
    • +
    • Audit everything — When you run the servers, you can verify that your security promises are real, not just marketing
    • +
    • Consent and autonomy — Your community sets its own security boundaries rather than accepting whatever a vendor's privacy policy allows
    • +
    +

    You wouldn't hold a sensitive strategy meeting in a room wired by someone else. Why would you plan your campaign on someone else's servers?

    +
    +

    Our Principles

    +

    Liberation First +Technology should center marginalized voices. The tools we build reflect the values we hold, and they shape the movements that use them.

    +

    Community Over Profit +Changemaker Lite is free and open source software, built by a cooperative — not a startup looking for an exit. There are no shareholders to satisfy, no venture capitalists to answer to. The software serves the community because that's the only thing it's designed to do.

    +

    Data Sovereignty +Communities should own their complete digital infrastructure. Not just the content — the servers, the databases, the encryption keys, and the ability to pack up and leave at any time.

    +

    Radical Accessibility +Self-hosted doesn't have to mean self-excluding. Changemaker Lite is designed for organizers, not sysadmins. If you can follow a guide to set up a WordPress site, you can run this platform.

    +
    +

    Further Reading

    +

    These articles explore the ideas behind Changemaker Lite in depth:

    + + + + + + + + + + + + + + + + + + + + +
  • + + + +
    + + + +
    + + + + +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/docs/services/index.html b/mkdocs/site/docs/services/index.html index 966dd41c..b7286b75 100644 --- a/mkdocs/site/docs/services/index.html +++ b/mkdocs/site/docs/services/index.html @@ -26,7 +26,7 @@ - + @@ -60,8 +60,12 @@ + + + + @@ -179,43 +183,63 @@ - + + +
    @@ -614,6 +750,8 @@ + + @@ -688,6 +826,12 @@ + + + + + + @@ -749,62 +893,24 @@ - - - - - - - - - - -
  • + - - - - - - - - - - - Feature Guides - - - - - - - - - - - - - - -
  • - - - - - - - - - - - - - + + + + + + + + + + + @@ -832,7 +938,7 @@ - Administration + Admin Guide @@ -865,6 +971,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
  • + + + + + + + + + + + + + User Guide + + + + + + + + + + + + + + +
  • + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1216,6 +1404,34 @@ + + + + + + +
  • + + + + + + + + Philosophy + + + + + + + + +
  • + + + + @@ -1363,6 +1579,17 @@
    + + +
  • + + + + Team Communication + + + +
  • @@ -1516,14 +1743,14 @@

    Video library server. Upload, metadata extraction (FFprobe), analytics, scheduled publishing, and public gallery. Shares the same PostgreSQL database.

    Port: 4100 · Container: changemaker-media-api

    -

    Media Guide

    +

    Media Guide

  • Admin GUI


    React single-page application (Vite + Ant Design + Zustand). Serves the admin dashboard, public campaign pages, volunteer portal, and media gallery — all from one build.

    Port: 3000 · Container: changemaker-v2-admin

    -

    Feature Guides

    +

    Feature Guides

  • PostgreSQL 16

    @@ -1576,7 +1803,7 @@

    MkDocs


    Material-themed documentation site with full-text search, blog, social cards, and Jinja2 template overrides. Two containers: live preview (dev) and static site (production).

    -

    Port: 4003 (dev) / 4001 (static) · Container: mkdocs-changemaker · Subdomain: docs.DOMAIN

    +

    Port: 4003 (dev) / 4004 (static) · Container: mkdocs-changemaker · Subdomain: docs.DOMAIN

    MkDocs Material

  • @@ -1639,6 +1866,40 @@

    Port: 8090 · Container: excalidraw-changemaker · Subdomain: draw.DOMAIN

    Excalidraw

  • +
  • +

    Vaultwarden

    +
    +

    Self-hosted Bitwarden-compatible password manager. Secure credential sharing for campaign teams. Requires HTTPS for account creation; local browsing works on HTTP.

    +

    Port: 8445 · Container: vaultwarden-changemaker · Subdomain: vault.DOMAIN

    +

    Vaultwarden Wiki

    +
  • + + +
    +

    Team Communication

    +
    +
      +
    • +

      Rocket.Chat

      +
      +

      Self-hosted team chat for volunteer coordination. Supports channels, direct messaging, threads, and file sharing. Embeddable in the admin dashboard via iframe. Enable with ENABLE_CHAT=true.

      +

      Port: 3000 (internal) · Container: rocketchat-changemaker · Subdomain: chat.DOMAIN

      +

      Rocket.Chat Docs

      +
    • +
    • +

      Jitsi Meet

      +
      +

      Self-hosted video conferencing with JWT authentication. Four containers (web, Prosody, Jicofo, JVB) provide the full video call stack. Integrated with Rocket.Chat for one-click calls from channels and DMs. Enable with ENABLE_MEET=true.

      +

      Containers: jitsi-web, jitsi-prosody, jitsi-jicofo, jitsi-jvb · Subdomain: meet.DOMAIN

      +

      Setup Guide · Jitsi Docs

      +
    • +
    • +

      Gancio

      +
      +

      Self-hosted event management platform. Automatic shift-to-event sync (when GANCIO_SYNC_ENABLED=true) publishes shifts as public events. Uses the shared PostgreSQL database. Embeddable calendar widget available for MkDocs pages.

      +

      Port: 8092 · Container: gancio-changemaker · Subdomain: events.DOMAIN

      +

      Gancio Docs

      +

    @@ -1672,7 +1933,7 @@

    Grafana


    Metrics visualization with 3 auto-provisioned dashboards: API Overview, Infrastructure, and Campaign Activity. Supports custom dashboards and alerting.

    -

    Port: 3001 · Container: grafana-changemaker · Subdomain: grafana.DOMAIN

    +

    Port: 3005 · Container: grafana-changemaker · Subdomain: grafana.DOMAIN

    Grafana Docs

  • @@ -1686,7 +1947,7 @@

    cAdvisor


    Container resource metrics. Exposes CPU, memory, network, and filesystem usage per container for Prometheus to scrape.

    -

    Port: 8080 · Container: cadvisor-changemaker

    +

    Port: 8086 · Container: cadvisor-changemaker

    cAdvisor GitHub

  • @@ -1781,7 +2042,7 @@ MkDocs (static) -4001 +4004 (root) default @@ -1828,6 +2089,30 @@ default +Vaultwarden +8445 +vault. +default + + +Rocket.Chat +— +chat. +default + + +Jitsi Meet +— +meet. +default + + +Gancio +8092 +events. +default + + Newt (tunnel) — — @@ -1841,7 +2126,7 @@ Grafana -3001 +3005 grafana. monitoring @@ -1853,7 +2138,7 @@ cAdvisor -8080 +8086 — monitoring @@ -2027,8 +2312,16 @@ + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/docs/troubleshooting/index.html b/mkdocs/site/docs/troubleshooting/index.html index 329c6716..13674081 100644 --- a/mkdocs/site/docs/troubleshooting/index.html +++ b/mkdocs/site/docs/troubleshooting/index.html @@ -19,14 +19,14 @@ - + - + @@ -60,8 +60,12 @@ + + + + @@ -179,43 +183,63 @@ - + + +
    @@ -614,6 +750,8 @@ + + @@ -688,6 +826,12 @@ + + + + + + @@ -749,62 +893,24 @@ - - - - - - - - - - -
  • + - - - - - - - - - - - Feature Guides - - - - - - - - - - - - - - -
  • - - - - - - - - - - - - - + + + + + + + + + + + @@ -832,7 +938,7 @@ - Administration + Admin Guide @@ -865,6 +971,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
  • + + + + + + + + + + + + + User Guide + + + + + + + + + + + + + + +
  • + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1216,6 +1404,34 @@ + + + + + + +
  • + + + + + + + + Philosophy + + + + + + + + +
  • + + + + @@ -1554,13 +1770,13 @@ - + @@ -1900,6 +2080,14 @@

    Import Canadian National Address Register data with province/city/postal filtering, coordinate projection, and streaming.

    2025 formatProj4Streaming
    +
    +
    +
    📆
    +

    Gancio Events

    +
    +

    Public event calendar synced from shifts via Gancio. OAuth integration, map markers for upcoming events, and embeddable GrapesJS block.

    +
    OAuth syncMap markersEmbeddable
    +
    @@ -1909,7 +2097,7 @@
    🎬

    Content & Media

    -

    Video library, page builder, documentation, and web IDE

    +

    Video, photos, playlists, page builder, documentation, and web IDE

    @@ -1918,8 +2106,8 @@
    📹

    Video Library

    -

    Upload, manage, and share videos with FFprobe metadata extraction, scheduled publishing, analytics, and a public gallery.

    -
    AnalyticsScheduling10GB uploads
    +

    Upload and manage videos with FFprobe metadata, scheduled publishing, view analytics, emoji reactions, threaded comments, and live chat.

    +
    AnalyticsLive chatScheduling
    @@ -1934,8 +2122,8 @@
    📖

    MkDocs Documentation

    -

    Material-themed documentation site with full-text search, blog, social cards, and Jinja2 template overrides.

    -
    Material themeLunr searchBlog
    +

    Material-themed docs with full-text search, blog, social cards, and Gitea-backed page comments with anonymous posting and moderation.

    +
    Material themeCommentsBlog
    @@ -1953,6 +2141,30 @@

    Collaborative diagramming and whiteboard tool. Plan canvassing routes, sketch campaign strategies, and brainstorm as a team.

    CollaborativeDiagramsReal-time
    +
    +
    +
    📷
    +

    Photo Management

    +
    +

    Album organization with bulk uploads, metadata extraction, and engagement tracking. Reactions, comments, and a public photo gallery.

    +
    AlbumsEngagementGallery
    +
    +
    +
    +
    🎵
    +

    Playlists

    +
    +

    Curated video collections with admin, user, and public playlists. Drag-reorder, sidebar navigation, featured carousel, and dedicated viewer page.

    +
    CuratedPublic/PrivateReorderable
    +
    +
    +
    +
    📲
    +

    Shorts Feed

    +
    +

    TikTok-style vertical video feed for clips under 60 seconds. Autoplay, sorting modes, and mobile-optimized swipeable interface.

    +
    Vertical videoAutoplayMobile-first
    +
    @@ -1962,7 +2174,7 @@
    📊

    Data & Automation

    -

    Database browsing, workflow automation, version control, and utilities

    +

    Database browsing, workflow automation, version control, search, and utilities

    @@ -1998,6 +2210,22 @@

    QR code generator for walk sheets, campaign materials, and event signage. Instant PNG generation.

    PNG outputEmbeddablePublic API
    +
    +
    +
    +

    Command Palette

    +
    +

    Global Ctrl+K search across pages, campaigns, locations, users, and settings. Fuzzy matching, recent items, and keyboard-driven navigation.

    +
    Ctrl+KFuzzy searchKeyboard-first
    +
    +
    +
    +
    +

    Navigation Settings

    +
    +

    Customizable public nav menu with feature toggles, custom external links, drag-reorder, and real-time preview. Control what visitors see.

    +
    Drag-reorderFeature flagsCustom links
    +
    @@ -2051,6 +2279,14 @@

    Self-hosted Bitwarden-compatible password manager. Secure credential sharing for your team with real-time sync and browser extensions.

    BitwardenTeam sharingEncrypted
    +
    +
    +
    👥
    +

    User Provisioning

    +
    +

    Automatic account sync across Rocket.Chat, Gitea, Vaultwarden, and Listmonk. Eager or lazy strategies with per-user status tracking and bulk sync.

    +
    4 servicesAuto-syncLifecycle hooks
    +
    @@ -2104,10 +2340,71 @@

    Promote donations, products, and subscriptions within the media gallery. Visibility targeting, scheduling, and click analytics.

    TargetingSchedulingCTR tracking
    +
    +
    +
    📝
    +

    Donation Pages

    +
    +

    Custom branded donation pages with configurable amounts, thank-you messages, and public slugs. Multiple campaigns with independent branding and goals.

    +
    Custom brandingSlug URLsGoals
    +
    - + +
    +
    + +
    +

    Social & Community

    +

    Friendships, activity feeds, achievements, groups, reactions, and real-time notifications

    +
    +
    +
    +
    +
    +
    👥
    +

    Friend System

    +
    +

    Friend requests, suggestions, pokes, cross-module badges on campaigns, shifts, and the map.

    +
    Friend requestsSuggestionsPoke
    +
    +
    +
    +
    📰
    +

    Activity Feed

    +
    +

    Real-time SSE feed of friend activity across campaigns, shifts, canvassing, and responses.

    +
    Real-timeSSECross-module
    +
    +
    +
    +
    🏆
    +

    Achievements & Notifications

    +
    +

    Milestone badges, real-time notification bell with friend requests, pokes, comments, and alerts.

    +
    BadgesBell UIReal-time
    +
    +
    +
    +
    👫
    +

    Groups & Teams

    +
    +

    Auto-groups for shift teams and campaign crews, custom groups, and shared updates.

    +
    Shift teamsCampaign crewsCustom groups
    +
    +
    +
    +
    😍
    +

    Reactions & Comments

    +
    +

    6 emoji reactions with floating animations, threaded comments with word-filter safety, pagination, and auto-notification.

    +
    6 emoji typesThreadedContent safety
    +
    +
    +
    + +
    🇨🇦
    @@ -2252,7 +2549,7 @@
    $0
    forever
      -
    • All 30+ campaign tools
    • +
    • All 45+ campaign tools
    • Unlimited users & data
    • Complete documentation
    • Community support
    • @@ -2353,7 +2650,7 @@

      Community

      @@ -2696,6 +2993,7 @@ { sel: '.branch[data-branch="data"]', headerSel: '.branch[data-branch="data"] .branch-header', cardSel: '.feature-node', color: '#22D3EE', angle: 0.22 }, { sel: '.branch[data-branch="devops"]', headerSel: '.branch[data-branch="devops"] .branch-header', cardSel: '.feature-node', color: '#FBBF24', angle: -0.08 }, { sel: '.branch[data-branch="fundraising"]', headerSel: '.branch[data-branch="fundraising"] .branch-header', cardSel: '.feature-node', color: '#EC4899', angle: 0.18 }, + { sel: '.branch[data-branch="social"]', headerSel: '.branch[data-branch="social"] .branch-header', cardSel: '.feature-node', color: '#38BDF8', angle: -0.18 }, { sel: '.branch[data-branch="sovereignty"]', headerSel: '.branch[data-branch="sovereignty"] .branch-header', cardSel: '.feature-node', color: '#F87171', angle: 0.08 }, { sel: '.live-sites', headerSel: '.live-sites .section-header', cardSel: '.site-card', color: '#8B5CF6', angle: -0.12 }, { sel: '#pricing', headerSel: '#pricing .section-header', cardSel: '.pricing-card, .cost-compare', color: '#C084FC', angle: 0.12 }, @@ -2709,17 +3007,31 @@ _pageH: 1, _rootY: 0, + isMobile: false, + init() { - if (window.innerWidth <= 768) return; - this.drawAll(); + this.isMobile = window.innerWidth <= 768; + this.redraw(); this.startScroll(); let resizeTimer; window.addEventListener('resize', () => { clearTimeout(resizeTimer); - resizeTimer = setTimeout(() => { this.drawAll(); this.updateScroll(); }, 300); + resizeTimer = setTimeout(() => { + this.isMobile = window.innerWidth <= 768; + this.redraw(); + this.updateScroll(); + }, 300); }); }, + redraw() { + if (this.isMobile) { + this.drawMobile(); + } else { + this.drawAll(); + } + }, + rand(seed) { const x = Math.sin(seed * 9301 + 49297) * 49297; return x - Math.floor(x); @@ -2780,6 +3092,92 @@ return c; }, + drawMobile() { + const old = document.getElementById('root-network'); + if (old) old.remove(); + this.tendrils = []; + + const rootSvgEl = document.querySelector('.hero-root-svg'); + if (!rootSvgEl) return; + + const rootRect = rootSvgEl.getBoundingClientRect(); + const rootX = rootRect.left + rootRect.width / 2; + const rootY = rootRect.top + window.scrollY + rootRect.height / 2; + + this._rootY = rootY; + this._pageH = document.documentElement.scrollHeight; + const pageW = document.documentElement.scrollWidth; + const spineX = 20; + + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('id', 'root-network'); + svg.setAttribute('class', 'root-network-svg'); + svg.setAttribute('aria-hidden', 'true'); + svg.style.height = this._pageH + 'px'; + svg.style.width = pageW + 'px'; + + // Collect junction points for each section + const junctions = []; + this.sections.forEach((sec, si) => { + const headerEl = document.querySelector(sec.headerSel); + if (!headerEl) return; + const hRect = headerEl.getBoundingClientRect(); + const jy = hRect.top + window.scrollY + hRect.height / 2; + junctions.push({ y: jy, color: sec.color, idx: si }); + }); + + if (junctions.length === 0) { document.body.appendChild(svg); return; } + + // --- Initial curve: root ball center → (spineX, first section Y) --- + const firstJ = junctions[0]; + const initD = this.wavyPath(rootX, rootY, spineX, firstJ.y, 42, 0.06); + const initPath = this.makeLine(svg, initD, '#8B5CF6', 2.0); + const initDot = this.makeDot(svg, spineX, firstJ.y, 3, firstJ.color); + this.tendrils.push({ path: initPath, dot: initDot, targetY: firstJ.y, len: 0, opacity: 0.7, isTrunk: true, sectionIdx: 0 }); + + // --- Spine segments + branch stubs --- + let prevY = firstJ.y; + for (let i = 1; i < junctions.length; i++) { + const j = junctions[i]; + const seed = 100 + i * 73; + + // Spine segment: vertical along spineX + const spineSW = this.taperSW(j.y, 1.8); + const spineD = this.wavyPath(spineX, prevY, spineX, j.y, seed, 0.03); + const spinePath = this.makeLine(svg, spineD, j.color, Math.max(spineSW, 1.0)); + const spineDot = this.makeDot(svg, spineX, j.y, 3, j.color); + this.tendrils.push({ path: spinePath, dot: spineDot, targetY: j.y, len: 0, opacity: 0.7, isTrunk: true, sectionIdx: j.idx }); + + // Branch stub: short horizontal reach to the right + const stubD = this.wavyPath(spineX, j.y, spineX + 50, j.y, seed + 37, 0.15); + const stubPath = this.makeLine(svg, stubD, j.color, 1.2); + this.tendrils.push({ path: stubPath, dot: null, targetY: j.y, len: 0, opacity: 0.5, parentY: j.y }); + + prevY = j.y; + } + + // Also add a branch stub for the first junction + const firstStubD = this.wavyPath(spineX, firstJ.y, spineX + 50, firstJ.y, 137, 0.15); + const firstStubPath = this.makeLine(svg, firstStubD, firstJ.color, 1.2); + this.tendrils.push({ path: firstStubPath, dot: null, targetY: firstJ.y, len: 0, opacity: 0.5, parentY: firstJ.y }); + + document.body.appendChild(svg); + + // Measure path lengths + set up dash animation + requestAnimationFrame(() => { + this.tendrils.forEach(t => { + try { + t.len = t.path.getTotalLength(); + t.path.style.strokeDasharray = t.len; + t.path.style.strokeDashoffset = t.len; + t.path.style.opacity = '0'; + if (t.dot) t.dot.style.opacity = '0'; + } catch(e) {} + }); + this.updateScroll(); + }); + }, + drawAll() { const old = document.getElementById('root-network'); if (old) old.remove(); diff --git a/mkdocs/site/javascripts/ad-widgets.js b/mkdocs/site/javascripts/ad-widgets.js new file mode 100644 index 00000000..41a873f0 --- /dev/null +++ b/mkdocs/site/javascripts/ad-widgets.js @@ -0,0 +1,262 @@ +/** + * Ad Widget Hydration for MkDocs + * + * Converts ad block placeholders (inserted via DocsPage toolbar) into + * rendered ad cards when pages are viewed in MkDocs. + * + * Supports: + * - .ad-specific-block[data-ad-id] — renders a specific ad by ID + * - .ad-slot-block[data-placement][data-variant] — renders a dynamic ad slot + * + * Reads API URL from window.PAYMENT_API_URL (set by env-config.js). + * Tracks impressions and clicks via POST /api/gallery-ads/track. + */ + +(function () { + 'use strict'; + + var API_URL = window.PAYMENT_API_URL || ''; + + /** Lighten/darken a hex color by an amount */ + function adjustColor(hex, amount) { + function clamp(v) { return Math.max(0, Math.min(255, v)); } + var h = hex.replace('#', ''); + var r = clamp(parseInt(h.substring(0, 2), 16) + amount); + var g = clamp(parseInt(h.substring(2, 4), 16) + amount); + var b = clamp(parseInt(h.substring(4, 6), 16) + amount); + return '#' + r.toString(16).padStart(2, '0') + g.toString(16).padStart(2, '0') + b.toString(16).padStart(2, '0'); + } + + /** Get or create a simple session ID for tracking */ + function getSessionId() { + var key = 'cm_ad_session'; + var id = sessionStorage.getItem(key); + if (!id) { + id = Math.random().toString(36).slice(2) + Date.now().toString(36); + sessionStorage.setItem(key, id); + } + return id; + } + + /** Track an impression or click */ + function trackEvent(adId, event) { + if (!API_URL) return; + fetch(API_URL + '/api/gallery-ads/track', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ adId: adId, event: event, sessionId: getSessionId() }), + }).catch(function () {}); // silent + } + + /** Render an ad card into a container element */ + function renderAdCard(container, ad) { + container.innerHTML = ''; + container.setAttribute('data-hydrated', 'true'); + + var isHighlight = ad.variant === 'highlight'; + var isMinimal = ad.variant === 'minimal'; + + var defaultPrimary = '#1677ff'; + var bgColor = ad.bgColor || defaultPrimary; + var bgGradient = ad.bgColor + ? 'linear-gradient(135deg, ' + ad.bgColor + ' 0%, ' + adjustColor(ad.bgColor, -30) + ' 100%)' + : isHighlight + ? 'linear-gradient(135deg, ' + defaultPrimary + ' 0%, ' + adjustColor(defaultPrimary, -40) + ' 100%)' + : 'linear-gradient(135deg, #1f1f2e 0%, #141422 100%)'; + + var borderStyle = isHighlight + ? '2px solid ' + bgColor + : '1px solid rgba(255,255,255,0.08)'; + + var card = document.createElement('div'); + card.style.cssText = 'border-radius:12px;overflow:hidden;border:' + borderStyle + + ';cursor:' + (ad.linkUrl ? 'pointer' : 'default') + + ';transition:all 0.2s ease;display:flex;flex-direction:column;max-width:400px;margin:16px auto;' + + (isHighlight ? 'box-shadow:0 0 20px ' + bgColor + '33;' : ''); + + // Click handler + card.addEventListener('click', function () { + trackEvent(ad.id, 'click'); + if (ad.linkUrl) { + if (ad.linkUrl.indexOf('http') === 0) { + window.open(ad.linkUrl, '_blank', 'noopener'); + } else { + window.location.href = ad.linkUrl; + } + } + }); + + // Top section (16:9 visual area) — skip for minimal variant + if (!isMinimal) { + var topBg = ad.imagePath + ? 'url(' + ad.imagePath + ') center/cover no-repeat' + : bgGradient; + var top = document.createElement('div'); + top.style.cssText = 'position:relative;padding-top:56.25%;background:' + topBg + ';'; + + var overlay = document.createElement('div'); + overlay.style.cssText = 'position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:20px;text-align:center;' + + (ad.imagePath ? 'background:rgba(0,0,0,0.5);' : ''); + + if (ad.iconEmoji) { + var emoji = document.createElement('span'); + emoji.style.cssText = 'font-size:36px;margin-bottom:8px;'; + emoji.textContent = ad.iconEmoji; + overlay.appendChild(emoji); + } + + var title = document.createElement('h4'); + title.style.cssText = 'color:#fff;margin:0;text-shadow:0 1px 3px rgba(0,0,0,0.3);font-size:16px;font-weight:600;'; + title.textContent = ad.title; + overlay.appendChild(title); + + if (ad.subtitle) { + var subtitle = document.createElement('p'); + subtitle.style.cssText = 'color:rgba(255,255,255,0.85);margin:8px 0 0;font-size:13px;max-width:240px;'; + subtitle.textContent = ad.subtitle; + overlay.appendChild(subtitle); + } + + top.appendChild(overlay); + card.appendChild(top); + } + + // Bottom section + var bottom = document.createElement('div'); + bottom.style.cssText = 'padding:' + (isMinimal ? '20px 16px' : '12px 16px') + + ';background:' + (isMinimal ? bgGradient : '#1b2838') + + ';display:flex;flex-direction:column;gap:8px;'; + + if (isMinimal) { + if (ad.iconEmoji) { + var emojiMin = document.createElement('span'); + emojiMin.style.fontSize = '24px'; + emojiMin.textContent = ad.iconEmoji; + bottom.appendChild(emojiMin); + } + var titleMin = document.createElement('h5'); + titleMin.style.cssText = 'color:#fff;margin:0;font-size:14px;font-weight:600;'; + titleMin.textContent = ad.title; + bottom.appendChild(titleMin); + + if (ad.subtitle) { + var subMin = document.createElement('p'); + subMin.style.cssText = 'color:rgba(255,255,255,0.6);font-size:12px;margin:0;'; + subMin.textContent = ad.subtitle; + bottom.appendChild(subMin); + } + } + + if (ad.ctaText) { + var cta = document.createElement('a'); + cta.textContent = ad.ctaText; + cta.href = ad.linkUrl || '#'; + cta.style.cssText = 'display:block;text-align:center;padding:6px 16px;border-radius:6px;font-size:13px;font-weight:500;text-decoration:none;' + + (ad.ctaStyle === 'primary' + ? 'background:' + defaultPrimary + ';color:#fff;' + : ad.ctaStyle === 'outline' + ? 'background:transparent;color:' + defaultPrimary + ';border:1px solid ' + defaultPrimary + ';' + : 'background:transparent;color:' + defaultPrimary + ';'); + cta.addEventListener('click', function (e) { + e.stopPropagation(); + }); + bottom.appendChild(cta); + } + + var promo = document.createElement('p'); + promo.style.cssText = 'font-size:10px;color:rgba(255,255,255,0.25);text-align:center;margin:0;'; + promo.textContent = 'Promoted'; + bottom.appendChild(promo); + + card.appendChild(bottom); + container.appendChild(card); + + // Impression tracking via IntersectionObserver + var impressionSent = false; + var timer = null; + var observer = new IntersectionObserver(function (entries) { + if (entries[0] && entries[0].isIntersecting) { + timer = setTimeout(function () { + if (!impressionSent) { + impressionSent = true; + trackEvent(ad.id, 'impression'); + } + }, 1000); + } else if (timer) { + clearTimeout(timer); + timer = null; + } + }, { threshold: 0.5 }); + observer.observe(card); + } + + /** Hydrate all ad blocks on the page */ + function hydrateAds() { + if (!API_URL) return; + + var specificBlocks = document.querySelectorAll('.ad-specific-block:not([data-hydrated])'); + var slotBlocks = document.querySelectorAll('.ad-slot-block:not([data-hydrated])'); + + if (specificBlocks.length === 0 && slotBlocks.length === 0) return; + + // Fetch all active ads (for both specific and slot hydration) + fetch(API_URL + '/api/gallery-ads?placement=docs') + .then(function (r) { return r.json(); }) + .then(function (ads) { + if (!Array.isArray(ads)) return; + + // Hydrate specific ad blocks + specificBlocks.forEach(function (el) { + var adId = parseInt(el.getAttribute('data-ad-id') || '0', 10); + if (!adId) return; + var ad = ads.find(function (a) { return a.id === adId; }); + if (!ad) { + // Ad not found or not active — try fetching all ads (placement filter may exclude it) + fetch(API_URL + '/api/gallery-ads') + .then(function (r) { return r.json(); }) + .then(function (allAds) { + var found = allAds.find(function (a) { return a.id === adId; }); + if (found) renderAdCard(el, found); + else el.style.display = 'none'; + }) + .catch(function () { el.style.display = 'none'; }); + return; + } + renderAdCard(el, ad); + }); + + // Hydrate dynamic ad slot blocks + if (slotBlocks.length > 0 && ads.length > 0) { + var idx = 0; + slotBlocks.forEach(function (el) { + var variant = el.getAttribute('data-variant') || 'standard'; + var match = ads.find(function (a) { return a.variant === variant; }) || ads[idx % ads.length]; + idx++; + if (match) renderAdCard(el, match); + else el.style.display = 'none'; + }); + } else if (slotBlocks.length > 0) { + slotBlocks.forEach(function (el) { el.style.display = 'none'; }); + } + }) + .catch(function (err) { + console.warn('[Ad Widgets] Failed to fetch ads:', err); + specificBlocks.forEach(function (el) { el.style.display = 'none'; }); + slotBlocks.forEach(function (el) { el.style.display = 'none'; }); + }); + } + + // Initialize when DOM is ready + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', hydrateAds); + } else { + hydrateAds(); + } + + // Re-initialize on MkDocs SPA navigation + if (typeof window.document$ !== 'undefined') { + window.document$.subscribe(function () { + setTimeout(hydrateAds, 100); + }); + } +})(); diff --git a/mkdocs/site/javascripts/docs-comments.js b/mkdocs/site/javascripts/docs-comments.js new file mode 100644 index 00000000..d345d14a --- /dev/null +++ b/mkdocs/site/javascripts/docs-comments.js @@ -0,0 +1,479 @@ +/** + * Docs Comments Widget — Gitea Issues-backed comments for MkDocs pages + * + * Loads approved comments from the Express API proxy, supports: + * - Anonymous comments (with moderation queue) + * - Gitea OAuth2 login for instant comments + * - Dark/light theme via MkDocs Material CSS vars + * - SPA re-init via document$.subscribe() + */ +(function () { + 'use strict'; + + // --- Config --- + function getApiUrl() { + // env-config.js sets window.PAYMENT_API_URL to the resolved API URL + return window.PAYMENT_API_URL || 'http://localhost:4000'; + } + + var API_BASE = ''; + var SESSION_KEY = 'docs-comment-gitea-token'; + var USER_KEY = 'docs-comment-gitea-user'; + var STATE_KEY = 'docs-comment-oauth-state'; + var RETURN_KEY = 'docs-comment-return-url'; + + // --- Helpers --- + + function escapeHtml(str) { + var div = document.createElement('div'); + div.appendChild(document.createTextNode(str)); + return div.innerHTML; + } + + /** + * Minimal inline markdown: **bold**, *italic*, `code`, [text](url) + */ + function renderInlineMarkdown(text) { + var html = escapeHtml(text); + // Code (backticks) + html = html.replace(/`([^`]+)`/g, '$1'); + // Bold + html = html.replace(/\*\*(.+?)\*\*/g, '$1'); + // Italic + html = html.replace(/\*(.+?)\*/g, '$1'); + // Links + html = html.replace( + /\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, + '$1' + ); + // Newlines + html = html.replace(/\n/g, '
      '); + return html; + } + + function timeAgo(dateStr) { + var now = Date.now(); + var then = new Date(dateStr).getTime(); + var diff = Math.floor((now - then) / 1000); + + if (diff < 60) return 'just now'; + if (diff < 3600) return Math.floor(diff / 60) + 'm ago'; + if (diff < 86400) return Math.floor(diff / 3600) + 'h ago'; + if (diff < 2592000) return Math.floor(diff / 86400) + 'd ago'; + return new Date(dateStr).toLocaleDateString(); + } + + function getInitials(name) { + return name + .split(/\s+/) + .map(function (w) { return w[0]; }) + .join('') + .toUpperCase() + .slice(0, 2); + } + + // --- Auth --- + + function getToken() { + try { return sessionStorage.getItem(SESSION_KEY); } catch { return null; } + } + + function getUser() { + try { + var data = sessionStorage.getItem(USER_KEY); + return data ? JSON.parse(data) : null; + } catch { return null; } + } + + function setAuth(token, user) { + try { + sessionStorage.setItem(SESSION_KEY, token); + sessionStorage.setItem(USER_KEY, JSON.stringify(user)); + } catch { /* sessionStorage unavailable */ } + } + + function clearAuth() { + try { + sessionStorage.removeItem(SESSION_KEY); + sessionStorage.removeItem(USER_KEY); + } catch { /* ignore */ } + } + + // --- API --- + + function fetchComments(pagePath, page, callback) { + var url = API_BASE + '/api/docs-comments/comments?pagePath=' + + encodeURIComponent(pagePath) + '&page=' + (page || 1); + + fetch(url) + .then(function (res) { + if (!res.ok) throw new Error('HTTP ' + res.status); + return res.json(); + }) + .then(function (data) { callback(null, data); }) + .catch(function (err) { callback(err); }); + } + + function postAnonymous(payload, callback) { + fetch(API_BASE + '/api/docs-comments/comments/anonymous', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) + .then(function (res) { + if (!res.ok) return res.json().then(function (d) { throw new Error(d.error || 'Error'); }); + return res.json(); + }) + .then(function (data) { callback(null, data); }) + .catch(function (err) { callback(err); }); + } + + function postAuthenticated(payload, token, callback) { + fetch(API_BASE + '/api/docs-comments/comments/authenticated', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Gitea-Token': token, + }, + body: JSON.stringify(payload), + }) + .then(function (res) { + if (!res.ok) return res.json().then(function (d) { throw new Error(d.error || 'Error'); }); + return res.json(); + }) + .then(function (data) { callback(null, data); }) + .catch(function (err) { callback(err); }); + } + + function fetchOAuthConfig(callback) { + fetch(API_BASE + '/api/docs-comments/oauth/config') + .then(function (res) { return res.json(); }) + .then(function (data) { callback(null, data); }) + .catch(function (err) { callback(err); }); + } + + function exchangeCode(code, redirectUri, callback) { + fetch(API_BASE + '/api/docs-comments/oauth/exchange', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ code: code, redirectUri: redirectUri }), + }) + .then(function (res) { + if (!res.ok) throw new Error('OAuth exchange failed'); + return res.json(); + }) + .then(function (data) { callback(null, data); }) + .catch(function (err) { callback(err); }); + } + + // --- Render --- + + function renderAvatar(comment) { + if (comment.avatarUrl) { + return ''; + } + return '' + escapeHtml(getInitials(comment.authorName)) + ''; + } + + function renderComment(comment) { + var fullDate = new Date(comment.createdAt).toLocaleString(); + return ( + '
      ' + + '
      ' + + renderAvatar(comment) + + '' + escapeHtml(comment.authorName) + '' + + (comment.isAnonymous ? 'Guest' : '') + + '' + + '
      ' + + '
      ' + renderInlineMarkdown(comment.body) + '
      ' + + '
      ' + ); + } + + function renderCommentList(data) { + if (!data.comments || data.comments.length === 0) { + return '

      No comments yet. Be the first to share your thoughts!

      '; + } + return data.comments.map(renderComment).join(''); + } + + function renderForm(pagePath, oauthConfig) { + var token = getToken(); + var user = getUser(); + + if (token && user) { + // Authenticated form + return ( + '
      ' + + '
      ' + + '' + + 'Commenting as ' + escapeHtml(user.name) + '' + + '' + + '
      ' + + '' + + '
      ' + + '' + + '
      ' + + '
      ' + ); + } + + // Anonymous form with optional OAuth login + var loginBtn = ''; + if (oauthConfig && oauthConfig.oauthEnabled) { + loginBtn = ''; + } + + return ( + '
      ' + + '
      ' + + '' + + '' + + '
      ' + + // Honeypot — hidden from humans + '' + + '' + + '
      ' + + '' + + loginBtn + + '
      ' + + '

      Guest comments are reviewed before appearing.

      ' + + '
      ' + ); + } + + // --- Main Init --- + + function initWidget(container) { + var pagePath = container.getAttribute('data-page-path') || ''; + if (!pagePath) return; + + API_BASE = getApiUrl(); + + container.innerHTML = + '
      ' + + '

      Comments

      ' + + '
      Loading comments...
      ' + + '
      ' + + '
      '; + + var listEl = container.querySelector('.dc-comments-list'); + var formContainer = container.querySelector('.dc-form-container'); + var oauthConfig = null; + + // Load OAuth config + comments in parallel + fetchOAuthConfig(function (err, config) { + if (!err && config) oauthConfig = config; + renderFormSection(); + }); + + loadComments(1); + + function loadComments(page) { + listEl.classList.add('dc-loading'); + listEl.innerHTML = 'Loading comments...'; + + fetchComments(pagePath, page, function (err, data) { + listEl.classList.remove('dc-loading'); + if (err) { + listEl.innerHTML = '

      Comments unavailable.

      '; + return; + } + listEl.innerHTML = renderCommentList(data); + }); + } + + function renderFormSection() { + formContainer.innerHTML = renderForm(pagePath, oauthConfig); + bindFormEvents(); + } + + function bindFormEvents() { + // Anonymous submit + var submitAnon = formContainer.querySelector('.dc-submit-anon'); + if (submitAnon) { + submitAnon.addEventListener('click', function () { + var name = formContainer.querySelector('.dc-anon-name').value.trim(); + var email = formContainer.querySelector('.dc-anon-email').value.trim(); + var body = formContainer.querySelector('.dc-anon-body').value.trim(); + var honeypot = formContainer.querySelector('.dc-honeypot').value; + + if (!name) { showFormError('Please enter your name.'); return; } + if (body.length < 10) { showFormError('Comment must be at least 10 characters.'); return; } + + submitAnon.disabled = true; + submitAnon.textContent = 'Posting...'; + + postAnonymous( + { pagePath: pagePath, authorName: name, authorEmail: email || undefined, body: body, website: honeypot }, + function (err) { + submitAnon.disabled = false; + submitAnon.textContent = 'Post as Guest'; + if (err) { showFormError(err.message || 'Failed to post comment.'); return; } + formContainer.querySelector('.dc-anon-body').value = ''; + showFormSuccess('Your comment has been submitted for review.'); + } + ); + }); + } + + // Authenticated submit + var submitAuth = formContainer.querySelector('.dc-submit-auth'); + if (submitAuth) { + submitAuth.addEventListener('click', function () { + var body = formContainer.querySelector('.dc-auth-body').value.trim(); + var token = getToken(); + + if (body.length < 10) { showFormError('Comment must be at least 10 characters.'); return; } + if (!token) { showFormError('Session expired. Please sign in again.'); clearAuth(); renderFormSection(); return; } + + submitAuth.disabled = true; + submitAuth.textContent = 'Posting...'; + + postAuthenticated( + { pagePath: pagePath, body: body }, + token, + function (err) { + submitAuth.disabled = false; + submitAuth.textContent = 'Post Comment'; + if (err) { + if (err.message && err.message.indexOf('401') !== -1) { + clearAuth(); + renderFormSection(); + showFormError('Session expired. Please sign in again.'); + return; + } + showFormError(err.message || 'Failed to post comment.'); + return; + } + formContainer.querySelector('.dc-auth-body').value = ''; + loadComments(1); + } + ); + }); + } + + // OAuth login + var loginBtn = formContainer.querySelector('.dc-login-btn'); + if (loginBtn && oauthConfig && oauthConfig.oauthEnabled) { + loginBtn.addEventListener('click', function () { + var state = Math.random().toString(36).slice(2); + try { + sessionStorage.setItem(STATE_KEY, state); + sessionStorage.setItem(RETURN_KEY, window.location.href); + } catch { /* ignore */ } + + var redirectUri = window.location.origin + '/comments/callback/'; + var url = oauthConfig.authorizeUrl + + '?client_id=' + encodeURIComponent(oauthConfig.clientId) + + '&redirect_uri=' + encodeURIComponent(redirectUri) + + '&response_type=code' + + '&state=' + encodeURIComponent(state); + + window.location.href = url; + }); + } + + // Logout + var logoutBtn = formContainer.querySelector('.dc-logout-btn'); + if (logoutBtn) { + logoutBtn.addEventListener('click', function () { + clearAuth(); + renderFormSection(); + }); + } + } + + function showFormError(msg) { + removeFormMessages(); + var el = document.createElement('p'); + el.className = 'dc-form-message dc-form-message--error'; + el.textContent = msg; + formContainer.appendChild(el); + setTimeout(function () { el.remove(); }, 5000); + } + + function showFormSuccess(msg) { + removeFormMessages(); + var el = document.createElement('p'); + el.className = 'dc-form-message dc-form-message--success'; + el.textContent = msg; + formContainer.appendChild(el); + setTimeout(function () { el.remove(); }, 5000); + } + + function removeFormMessages() { + var msgs = formContainer.querySelectorAll('.dc-form-message'); + for (var i = 0; i < msgs.length; i++) msgs[i].remove(); + } + } + + // --- OAuth Callback Handler --- + + function handleOAuthCallback() { + var params = new URLSearchParams(window.location.search); + var code = params.get('code'); + var state = params.get('state'); + + if (!code) return; + + // Verify state + var savedState; + try { savedState = sessionStorage.getItem(STATE_KEY); } catch { /* ignore */ } + if (savedState && state !== savedState) { + console.warn('[DocsComments] OAuth state mismatch'); + return; + } + + API_BASE = getApiUrl(); + var redirectUri = window.location.origin + '/comments/callback/'; + + exchangeCode(code, redirectUri, function (err, data) { + if (err || !data || !data.accessToken) { + console.error('[DocsComments] OAuth exchange failed:', err); + return; + } + + setAuth(data.accessToken, data.user); + + // Redirect back to original page + var returnUrl; + try { returnUrl = sessionStorage.getItem(RETURN_KEY); } catch { /* ignore */ } + try { + sessionStorage.removeItem(STATE_KEY); + sessionStorage.removeItem(RETURN_KEY); + } catch { /* ignore */ } + + window.location.href = returnUrl || '/'; + }); + } + + // --- SPA Init --- + + function init() { + // Check if this is the OAuth callback page + if (window.location.pathname.indexOf('/comments/callback') !== -1) { + handleOAuthCallback(); + return; + } + + var container = document.getElementById('docs-comments'); + if (container) { + initWidget(container); + } + } + + // MkDocs Material SPA navigation support + if (typeof document$ !== 'undefined') { + document$.subscribe(function () { + init(); + }); + } else { + // Fallback for non-SPA or non-Material builds + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } + } +})(); diff --git a/mkdocs/site/lander/index.html b/mkdocs/site/lander/index.html index 22cec2da..8fe0db0d 100644 --- a/mkdocs/site/lander/index.html +++ b/mkdocs/site/lander/index.html @@ -40,6 +40,7 @@ --branch-devops: #FBBF24; --branch-sovereignty: #F87171; --branch-fundraising: #EC4899; + --branch-social: #38BDF8; /* Surfaces — dark */ --bg-deep: #0F172A; @@ -768,6 +769,130 @@ line-height: 1.6; } + /* ============================================ + GROWING CHANGE — Philosophy Bridge + ============================================ */ + .growing-change { + background: var(--bg-deep); + border-top: 1px solid var(--border-color); + border-bottom: 1px solid var(--border-color); + } + + .growing-change-content { + max-width: 840px; + margin: 0 auto; + text-align: center; + } + + .growing-change-card { + background: rgba(30, 41, 59, 0.85); + border: 1px solid rgba(148, 163, 184, 0.12); + border-radius: var(--radius-lg); + padding: 2.5rem 3rem; + margin-bottom: 3rem; + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + position: relative; + z-index: 2; + } + + [data-theme="light"] .growing-change-card { + background: rgba(255, 255, 255, 0.82); + border-color: rgba(100, 116, 139, 0.15); + } + + .growing-change-card h2 { + margin-bottom: 1.5rem; + background: linear-gradient(135deg, var(--primary-light) 0%, #C084FC 50%, var(--success) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + } + + .growing-lead { + font-size: 1.2rem; + color: var(--text-secondary); + line-height: 1.8; + margin-bottom: 1.25rem; + } + + .growing-change-card > p { + color: var(--text-secondary); + font-size: 1.05rem; + line-height: 1.7; + } + + .growing-change-card > p:last-child { + margin-bottom: 0; + } + + .growing-callout { + font-size: 0.95rem; + color: var(--text-muted); + font-style: italic; + border-left: 3px solid var(--primary-light); + padding-left: 1rem; + margin-top: 1.25rem; + } + + .growing-cta { + text-align: center; + margin-top: 2.5rem; + } + + .growing-pillars { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1.5rem; + text-align: left; + } + + .growing-pillar { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius); + padding: 1.75rem; + transition: all var(--transition); + border-top: 2px solid var(--success); + } + + .growing-pillar:nth-child(2) { + border-top-color: var(--primary-light); + } + + .growing-pillar:nth-child(3) { + border-top-color: var(--branch-content); + } + + .growing-pillar:hover { + transform: translateY(-3px); + box-shadow: var(--shadow-md); + border-color: var(--myc-node-border); + } + + .pillar-icon { + font-size: 1.75rem; + margin-bottom: 0.75rem; + } + + .growing-pillar h4 { + font-size: 1.05rem; + font-weight: 700; + margin-bottom: 0.5rem; + } + + .growing-pillar p { + color: var(--text-secondary); + font-size: 0.9rem; + line-height: 1.6; + } + + @media (max-width: 768px) { + .growing-pillars { + grid-template-columns: 1fr; + } + } + /* ============================================ FEATURE NETWORK — THE CORE VISUAL ============================================ */ @@ -889,6 +1014,7 @@ .branch-icon.devops { background: rgba(251, 191, 36, 0.15); border: 1px solid rgba(251, 191, 36, 0.3); } .branch-icon.sovereignty { background: rgba(248, 113, 113, 0.15); border: 1px solid rgba(248, 113, 113, 0.3); } .branch-icon.fundraising { background: rgba(236, 72, 153, 0.15); border: 1px solid rgba(236, 72, 153, 0.3); } + .branch-icon.social { background: rgba(56, 189, 248, 0.15); border: 1px solid rgba(56, 189, 248, 0.3); } .branch-title h3 { font-size: 1.35rem; @@ -938,6 +1064,7 @@ .branch-devops .feature-node { border-top: 2px solid var(--branch-devops); } .branch-sovereignty .feature-node { border-top: 2px solid var(--branch-sovereignty); } .branch-fundraising .feature-node { border-top: 2px solid var(--branch-fundraising); } + .branch-social .feature-node { border-top: 2px solid var(--branch-social); } .branch-comm .feature-node:hover { border-color: var(--branch-comm); box-shadow: 0 0 20px rgba(192,132,252,0.2); } .branch-map .feature-node:hover { border-color: var(--branch-map); box-shadow: 0 0 20px rgba(52,211,153,0.2); } @@ -946,6 +1073,7 @@ .branch-devops .feature-node:hover { border-color: var(--branch-devops); box-shadow: 0 0 20px rgba(251,191,36,0.2); } .branch-sovereignty .feature-node:hover { border-color: var(--branch-sovereignty); box-shadow: 0 0 20px rgba(248,113,113,0.2); } .branch-fundraising .feature-node:hover { border-color: var(--branch-fundraising); box-shadow: 0 0 20px rgba(236,72,153,0.2); } + .branch-social .feature-node:hover { border-color: var(--branch-social); box-shadow: 0 0 20px rgba(56,189,248,0.2); } /* Branch-specific node icon tinting */ .branch-comm .node-icon { background: rgba(192,132,252,0.12); } @@ -955,6 +1083,7 @@ .branch-devops .node-icon { background: rgba(251,191,36,0.12); } .branch-sovereignty .node-icon { background: rgba(248,113,113,0.12); } .branch-fundraising .node-icon { background: rgba(236,72,153,0.12); } + .branch-social .node-icon { background: rgba(56,189,248,0.12); } .node-header { display: flex; @@ -1430,8 +1559,8 @@ .hero { min-height: auto; padding-top: calc(var(--header-height) + 2rem); padding-bottom: 3rem; } .hero h1 { font-size: 2rem; } - .hero-root-glow { width: 300px; height: 300px; top: auto; bottom: 0; } - .hero-root-svg { top: auto; bottom: 0; transform: translate(-50%, 20%); } + .hero-root-glow { width: 250px; height: 250px; top: 50%; bottom: auto; } + .hero-root-svg { top: 50%; bottom: auto; transform: translate(-50%, -50%); width: 250px; height: 250px; } .hero-stats { grid-template-columns: repeat(2, 1fr); @@ -1447,9 +1576,7 @@ } .branch { padding-left: 0; } - .root-network-svg { display: none; } .floating-elements { display: none; } - .hero-root-svg { width: 300px; height: 300px; } .sites-grid { grid-template-columns: 1fr; @@ -1679,13 +1806,14 @@
      Self-Hosted Campaign Infrastructure

      Grow Power.
      Don't Rent It.

      - A deeply integrated and opinionated collection of free and open source services and production ready applications for growing political movements. Campaigns, canvassing, fundraising, team chat, and media — all on your own infrastructure. - No corporate surveillance. No foreign interference. No monthly ransoms. Free and open source. + Run your campaigns, canvassing, fundraising, team chat, media, and more — all on your own infrastructure. + No corporate surveillance. No foreign interference. No monthly ransoms. + A free and open source toolkit built for growing political movements.

      @@ -1716,7 +1844,7 @@
      Self-Hosted
    -
    30+
    +
    45+
    Integrated Tools
    @@ -1771,6 +1899,42 @@
    + +
    +
    +
    +
    +

    Software Should Grow Power, Not Extract It

    +

    Most campaign and political software is extractive by nature — designed to pull information from a community in order to influence politics. Your voter data in corporate clouds. Your strategies readable by foreign jurisdictions. Your movement’s future in someone else’s hands.

    +

    Changemaker asks a different question: “what tools are needed to grow change in a community?” Growing change means making connections between people, providing access to tools that create new opportunities, and deeply understanding the wants and needs of your movement — on infrastructure you control.

    +

    Organizational independence requires technological independence. Socialist movements will never outspend capital — but a thousand neighborhood mailing lists has more potential impact than any single organization. Workers, with the right tools, will build the future.

    +
    +
    +
    +
    🤝
    +

    Distribute Power

    +

    Decentralized organizing is the way out. When knowledge and tools are widely distributed — not gatekept by leadership or locked behind vendor paywalls — movements become resilient.

    +
    +
    +
    🔒
    +

    Own Your Secrets

    +

    If you do politics, who is reading your secrets? Corporate platforms extract intelligence systematically. Self-hosted infrastructure means your strategies stay yours — no algorithmic surveillance, no foreign data laws, no backdoors.

    +
    +
    +
    🌱
    +

    De-Corp Your Stack

    +

    Every subscription to corporate software funds the machine you’re fighting. Free and open source tools reduce dependence on capital, eliminate vendor lock-in, and keep your movement’s resources where they belong — in the community.

    +
    +
    + +
    +
    +
    + @@ -1778,7 +1942,7 @@

    The Network

    -

    30+ tools connected — each node strengthens the whole

    +

    50+ tools connected — each node strengthens the whole

    @@ -1787,7 +1951,7 @@
    📨

    Communication

    -

    Email campaigns, newsletters, advocacy, and public engagement

    +

    Email campaigns, SMS outreach, newsletters, advocacy, and team chat

    @@ -1839,6 +2003,22 @@

    Async notification queue for admin alerts and volunteer feedback. Shift reminders, session summaries, and signup confirmations.

    BullMQRemindersSummaries
    +
    +
    +
    📱
    +

    SMS Campaigns

    +
    +

    Text message outreach via Termux Android bridge. Contact lists, templated campaigns, delivery tracking, response sync, and device health monitoring.

    +
    Termux bridgeBullMQ queueResponse sync
    +
    +
    +
    +
    🗨
    +

    Chat Widget

    +
    +

    Floating Rocket.Chat panel for logged-in team members. Minimizable FAB, auth-gated access, and settings-toggleable visibility across the admin interface.

    +
    Rocket.ChatAuth-gatedFloating
    +
    @@ -1900,6 +2080,14 @@

    Import Canadian National Address Register data with province/city/postal filtering, coordinate projection, and streaming.

    2025 formatProj4Streaming
    +
    +
    +
    📆
    +

    Gancio Events

    +
    +

    Public event calendar synced from shifts via Gancio. OAuth integration, map markers for upcoming events, and embeddable GrapesJS block.

    +
    OAuth syncMap markersEmbeddable
    +
    @@ -1909,7 +2097,7 @@
    🎬

    Content & Media

    -

    Video library, page builder, documentation, and web IDE

    +

    Video, photos, playlists, page builder, documentation, and web IDE

    @@ -1918,8 +2106,8 @@
    📹

    Video Library

    -

    Upload, manage, and share videos with FFprobe metadata extraction, scheduled publishing, analytics, and a public gallery.

    -
    AnalyticsScheduling10GB uploads
    +

    Upload and manage videos with FFprobe metadata, scheduled publishing, view analytics, emoji reactions, threaded comments, and live chat.

    +
    AnalyticsLive chatScheduling
    @@ -1934,8 +2122,8 @@
    📖

    MkDocs Documentation

    -

    Material-themed documentation site with full-text search, blog, social cards, and Jinja2 template overrides.

    -
    Material themeLunr searchBlog
    +

    Material-themed docs with full-text search, blog, social cards, and Gitea-backed page comments with anonymous posting and moderation.

    +
    Material themeCommentsBlog
    @@ -1953,6 +2141,30 @@

    Collaborative diagramming and whiteboard tool. Plan canvassing routes, sketch campaign strategies, and brainstorm as a team.

    CollaborativeDiagramsReal-time
    +
    +
    +
    📷
    +

    Photo Management

    +
    +

    Album organization with bulk uploads, metadata extraction, and engagement tracking. Reactions, comments, and a public photo gallery.

    +
    AlbumsEngagementGallery
    +
    +
    +
    +
    🎵
    +

    Playlists

    +
    +

    Curated video collections with admin, user, and public playlists. Drag-reorder, sidebar navigation, featured carousel, and dedicated viewer page.

    +
    CuratedPublic/PrivateReorderable
    +
    +
    +
    +
    📲
    +

    Shorts Feed

    +
    +

    TikTok-style vertical video feed for clips under 60 seconds. Autoplay, sorting modes, and mobile-optimized swipeable interface.

    +
    Vertical videoAutoplayMobile-first
    +
    @@ -1962,7 +2174,7 @@
    📊

    Data & Automation

    -

    Database browsing, workflow automation, version control, and utilities

    +

    Database browsing, workflow automation, version control, search, and utilities

    @@ -1998,6 +2210,22 @@

    QR code generator for walk sheets, campaign materials, and event signage. Instant PNG generation.

    PNG outputEmbeddablePublic API
    +
    +
    +
    +

    Command Palette

    +
    +

    Global Ctrl+K search across pages, campaigns, locations, users, and settings. Fuzzy matching, recent items, and keyboard-driven navigation.

    +
    Ctrl+KFuzzy searchKeyboard-first
    +
    +
    +
    +
    +

    Navigation Settings

    +
    +

    Customizable public nav menu with feature toggles, custom external links, drag-reorder, and real-time preview. Control what visitors see.

    +
    Drag-reorderFeature flagsCustom links
    +
    @@ -2051,6 +2279,14 @@

    Self-hosted Bitwarden-compatible password manager. Secure credential sharing for your team with real-time sync and browser extensions.

    BitwardenTeam sharingEncrypted
    +
    +
    +
    👥
    +

    User Provisioning

    +
    +

    Automatic account sync across Rocket.Chat, Gitea, Vaultwarden, and Listmonk. Eager or lazy strategies with per-user status tracking and bulk sync.

    +
    4 servicesAuto-syncLifecycle hooks
    +
    @@ -2104,10 +2340,71 @@

    Promote donations, products, and subscriptions within the media gallery. Visibility targeting, scheduling, and click analytics.

    TargetingSchedulingCTR tracking
    +
    +
    +
    📝
    +

    Donation Pages

    +
    +

    Custom branded donation pages with configurable amounts, thank-you messages, and public slugs. Multiple campaigns with independent branding and goals.

    +
    Custom brandingSlug URLsGoals
    +
    - + +
    +
    + +
    +

    Social & Community

    +

    Friendships, activity feeds, achievements, groups, reactions, and real-time notifications

    +
    +
    +
    +
    +
    +
    👥
    +

    Friend System

    +
    +

    Friend requests, suggestions, pokes, cross-module badges on campaigns, shifts, and the map.

    +
    Friend requestsSuggestionsPoke
    +
    +
    +
    +
    📰
    +

    Activity Feed

    +
    +

    Real-time SSE feed of friend activity across campaigns, shifts, canvassing, and responses.

    +
    Real-timeSSECross-module
    +
    +
    +
    +
    🏆
    +

    Achievements & Notifications

    +
    +

    Milestone badges, real-time notification bell with friend requests, pokes, comments, and alerts.

    +
    BadgesBell UIReal-time
    +
    +
    +
    +
    👫
    +

    Groups & Teams

    +
    +

    Auto-groups for shift teams and campaign crews, custom groups, and shared updates.

    +
    Shift teamsCampaign crewsCustom groups
    +
    +
    +
    +
    😍
    +

    Reactions & Comments

    +
    +

    6 emoji reactions with floating animations, threaded comments with word-filter safety, pagination, and auto-notification.

    +
    6 emoji typesThreadedContent safety
    +
    +
    +
    + +
    🇨🇦
    @@ -2252,7 +2549,7 @@
    $0
    forever
      -
    • All 30+ campaign tools
    • +
    • All 45+ campaign tools
    • Unlimited users & data
    • Complete documentation
    • Community support
    • @@ -2353,7 +2650,7 @@

      Community

      @@ -2696,6 +2993,7 @@ { sel: '.branch[data-branch="data"]', headerSel: '.branch[data-branch="data"] .branch-header', cardSel: '.feature-node', color: '#22D3EE', angle: 0.22 }, { sel: '.branch[data-branch="devops"]', headerSel: '.branch[data-branch="devops"] .branch-header', cardSel: '.feature-node', color: '#FBBF24', angle: -0.08 }, { sel: '.branch[data-branch="fundraising"]', headerSel: '.branch[data-branch="fundraising"] .branch-header', cardSel: '.feature-node', color: '#EC4899', angle: 0.18 }, + { sel: '.branch[data-branch="social"]', headerSel: '.branch[data-branch="social"] .branch-header', cardSel: '.feature-node', color: '#38BDF8', angle: -0.18 }, { sel: '.branch[data-branch="sovereignty"]', headerSel: '.branch[data-branch="sovereignty"] .branch-header', cardSel: '.feature-node', color: '#F87171', angle: 0.08 }, { sel: '.live-sites', headerSel: '.live-sites .section-header', cardSel: '.site-card', color: '#8B5CF6', angle: -0.12 }, { sel: '#pricing', headerSel: '#pricing .section-header', cardSel: '.pricing-card, .cost-compare', color: '#C084FC', angle: 0.12 }, @@ -2709,17 +3007,31 @@ _pageH: 1, _rootY: 0, + isMobile: false, + init() { - if (window.innerWidth <= 768) return; - this.drawAll(); + this.isMobile = window.innerWidth <= 768; + this.redraw(); this.startScroll(); let resizeTimer; window.addEventListener('resize', () => { clearTimeout(resizeTimer); - resizeTimer = setTimeout(() => { this.drawAll(); this.updateScroll(); }, 300); + resizeTimer = setTimeout(() => { + this.isMobile = window.innerWidth <= 768; + this.redraw(); + this.updateScroll(); + }, 300); }); }, + redraw() { + if (this.isMobile) { + this.drawMobile(); + } else { + this.drawAll(); + } + }, + rand(seed) { const x = Math.sin(seed * 9301 + 49297) * 49297; return x - Math.floor(x); @@ -2780,6 +3092,92 @@ return c; }, + drawMobile() { + const old = document.getElementById('root-network'); + if (old) old.remove(); + this.tendrils = []; + + const rootSvgEl = document.querySelector('.hero-root-svg'); + if (!rootSvgEl) return; + + const rootRect = rootSvgEl.getBoundingClientRect(); + const rootX = rootRect.left + rootRect.width / 2; + const rootY = rootRect.top + window.scrollY + rootRect.height / 2; + + this._rootY = rootY; + this._pageH = document.documentElement.scrollHeight; + const pageW = document.documentElement.scrollWidth; + const spineX = 20; + + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('id', 'root-network'); + svg.setAttribute('class', 'root-network-svg'); + svg.setAttribute('aria-hidden', 'true'); + svg.style.height = this._pageH + 'px'; + svg.style.width = pageW + 'px'; + + // Collect junction points for each section + const junctions = []; + this.sections.forEach((sec, si) => { + const headerEl = document.querySelector(sec.headerSel); + if (!headerEl) return; + const hRect = headerEl.getBoundingClientRect(); + const jy = hRect.top + window.scrollY + hRect.height / 2; + junctions.push({ y: jy, color: sec.color, idx: si }); + }); + + if (junctions.length === 0) { document.body.appendChild(svg); return; } + + // --- Initial curve: root ball center → (spineX, first section Y) --- + const firstJ = junctions[0]; + const initD = this.wavyPath(rootX, rootY, spineX, firstJ.y, 42, 0.06); + const initPath = this.makeLine(svg, initD, '#8B5CF6', 2.0); + const initDot = this.makeDot(svg, spineX, firstJ.y, 3, firstJ.color); + this.tendrils.push({ path: initPath, dot: initDot, targetY: firstJ.y, len: 0, opacity: 0.7, isTrunk: true, sectionIdx: 0 }); + + // --- Spine segments + branch stubs --- + let prevY = firstJ.y; + for (let i = 1; i < junctions.length; i++) { + const j = junctions[i]; + const seed = 100 + i * 73; + + // Spine segment: vertical along spineX + const spineSW = this.taperSW(j.y, 1.8); + const spineD = this.wavyPath(spineX, prevY, spineX, j.y, seed, 0.03); + const spinePath = this.makeLine(svg, spineD, j.color, Math.max(spineSW, 1.0)); + const spineDot = this.makeDot(svg, spineX, j.y, 3, j.color); + this.tendrils.push({ path: spinePath, dot: spineDot, targetY: j.y, len: 0, opacity: 0.7, isTrunk: true, sectionIdx: j.idx }); + + // Branch stub: short horizontal reach to the right + const stubD = this.wavyPath(spineX, j.y, spineX + 50, j.y, seed + 37, 0.15); + const stubPath = this.makeLine(svg, stubD, j.color, 1.2); + this.tendrils.push({ path: stubPath, dot: null, targetY: j.y, len: 0, opacity: 0.5, parentY: j.y }); + + prevY = j.y; + } + + // Also add a branch stub for the first junction + const firstStubD = this.wavyPath(spineX, firstJ.y, spineX + 50, firstJ.y, 137, 0.15); + const firstStubPath = this.makeLine(svg, firstStubD, firstJ.color, 1.2); + this.tendrils.push({ path: firstStubPath, dot: null, targetY: firstJ.y, len: 0, opacity: 0.5, parentY: firstJ.y }); + + document.body.appendChild(svg); + + // Measure path lengths + set up dash animation + requestAnimationFrame(() => { + this.tendrils.forEach(t => { + try { + t.len = t.path.getTotalLength(); + t.path.style.strokeDasharray = t.len; + t.path.style.strokeDashoffset = t.len; + t.path.style.opacity = '0'; + if (t.dot) t.dot.style.opacity = '0'; + } catch(e) {} + }); + this.updateScroll(); + }); + }, + drawAll() { const old = document.getElementById('root-network'); if (old) old.remove(); diff --git a/mkdocs/site/main/index.html b/mkdocs/site/main/index.html index 06c8ddf4..bcba2cd3 100644 --- a/mkdocs/site/main/index.html +++ b/mkdocs/site/main/index.html @@ -22,7 +22,7 @@ - + @@ -56,8 +56,12 @@ + + + + @@ -170,43 +174,63 @@ - + + +
      @@ -603,6 +739,8 @@ + + @@ -877,8 +1015,16 @@ + + + + + + + + \ No newline at end of file diff --git a/mkdocs/site/overrides/lander.html b/mkdocs/site/overrides/lander.html index 129edc7b..7597a80b 100644 --- a/mkdocs/site/overrides/lander.html +++ b/mkdocs/site/overrides/lander.html @@ -40,6 +40,7 @@ --branch-devops: #FBBF24; --branch-sovereignty: #F87171; --branch-fundraising: #EC4899; + --branch-social: #38BDF8; /* Surfaces — dark */ --bg-deep: #0F172A; @@ -768,6 +769,130 @@ line-height: 1.6; } + /* ============================================ + GROWING CHANGE — Philosophy Bridge + ============================================ */ + .growing-change { + background: var(--bg-deep); + border-top: 1px solid var(--border-color); + border-bottom: 1px solid var(--border-color); + } + + .growing-change-content { + max-width: 840px; + margin: 0 auto; + text-align: center; + } + + .growing-change-card { + background: rgba(30, 41, 59, 0.85); + border: 1px solid rgba(148, 163, 184, 0.12); + border-radius: var(--radius-lg); + padding: 2.5rem 3rem; + margin-bottom: 3rem; + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + position: relative; + z-index: 2; + } + + [data-theme="light"] .growing-change-card { + background: rgba(255, 255, 255, 0.82); + border-color: rgba(100, 116, 139, 0.15); + } + + .growing-change-card h2 { + margin-bottom: 1.5rem; + background: linear-gradient(135deg, var(--primary-light) 0%, #C084FC 50%, var(--success) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + } + + .growing-lead { + font-size: 1.2rem; + color: var(--text-secondary); + line-height: 1.8; + margin-bottom: 1.25rem; + } + + .growing-change-card > p { + color: var(--text-secondary); + font-size: 1.05rem; + line-height: 1.7; + } + + .growing-change-card > p:last-child { + margin-bottom: 0; + } + + .growing-callout { + font-size: 0.95rem; + color: var(--text-muted); + font-style: italic; + border-left: 3px solid var(--primary-light); + padding-left: 1rem; + margin-top: 1.25rem; + } + + .growing-cta { + text-align: center; + margin-top: 2.5rem; + } + + .growing-pillars { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1.5rem; + text-align: left; + } + + .growing-pillar { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: var(--radius); + padding: 1.75rem; + transition: all var(--transition); + border-top: 2px solid var(--success); + } + + .growing-pillar:nth-child(2) { + border-top-color: var(--primary-light); + } + + .growing-pillar:nth-child(3) { + border-top-color: var(--branch-content); + } + + .growing-pillar:hover { + transform: translateY(-3px); + box-shadow: var(--shadow-md); + border-color: var(--myc-node-border); + } + + .pillar-icon { + font-size: 1.75rem; + margin-bottom: 0.75rem; + } + + .growing-pillar h4 { + font-size: 1.05rem; + font-weight: 700; + margin-bottom: 0.5rem; + } + + .growing-pillar p { + color: var(--text-secondary); + font-size: 0.9rem; + line-height: 1.6; + } + + @media (max-width: 768px) { + .growing-pillars { + grid-template-columns: 1fr; + } + } + /* ============================================ FEATURE NETWORK — THE CORE VISUAL ============================================ */ @@ -889,6 +1014,7 @@ .branch-icon.devops { background: rgba(251, 191, 36, 0.15); border: 1px solid rgba(251, 191, 36, 0.3); } .branch-icon.sovereignty { background: rgba(248, 113, 113, 0.15); border: 1px solid rgba(248, 113, 113, 0.3); } .branch-icon.fundraising { background: rgba(236, 72, 153, 0.15); border: 1px solid rgba(236, 72, 153, 0.3); } + .branch-icon.social { background: rgba(56, 189, 248, 0.15); border: 1px solid rgba(56, 189, 248, 0.3); } .branch-title h3 { font-size: 1.35rem; @@ -938,6 +1064,7 @@ .branch-devops .feature-node { border-top: 2px solid var(--branch-devops); } .branch-sovereignty .feature-node { border-top: 2px solid var(--branch-sovereignty); } .branch-fundraising .feature-node { border-top: 2px solid var(--branch-fundraising); } + .branch-social .feature-node { border-top: 2px solid var(--branch-social); } .branch-comm .feature-node:hover { border-color: var(--branch-comm); box-shadow: 0 0 20px rgba(192,132,252,0.2); } .branch-map .feature-node:hover { border-color: var(--branch-map); box-shadow: 0 0 20px rgba(52,211,153,0.2); } @@ -946,6 +1073,7 @@ .branch-devops .feature-node:hover { border-color: var(--branch-devops); box-shadow: 0 0 20px rgba(251,191,36,0.2); } .branch-sovereignty .feature-node:hover { border-color: var(--branch-sovereignty); box-shadow: 0 0 20px rgba(248,113,113,0.2); } .branch-fundraising .feature-node:hover { border-color: var(--branch-fundraising); box-shadow: 0 0 20px rgba(236,72,153,0.2); } + .branch-social .feature-node:hover { border-color: var(--branch-social); box-shadow: 0 0 20px rgba(56,189,248,0.2); } /* Branch-specific node icon tinting */ .branch-comm .node-icon { background: rgba(192,132,252,0.12); } @@ -955,6 +1083,7 @@ .branch-devops .node-icon { background: rgba(251,191,36,0.12); } .branch-sovereignty .node-icon { background: rgba(248,113,113,0.12); } .branch-fundraising .node-icon { background: rgba(236,72,153,0.12); } + .branch-social .node-icon { background: rgba(56,189,248,0.12); } .node-header { display: flex; @@ -1430,8 +1559,8 @@ .hero { min-height: auto; padding-top: calc(var(--header-height) + 2rem); padding-bottom: 3rem; } .hero h1 { font-size: 2rem; } - .hero-root-glow { width: 300px; height: 300px; top: auto; bottom: 0; } - .hero-root-svg { top: auto; bottom: 0; transform: translate(-50%, 20%); } + .hero-root-glow { width: 250px; height: 250px; top: 50%; bottom: auto; } + .hero-root-svg { top: 50%; bottom: auto; transform: translate(-50%, -50%); width: 250px; height: 250px; } .hero-stats { grid-template-columns: repeat(2, 1fr); @@ -1447,9 +1576,7 @@ } .branch { padding-left: 0; } - .root-network-svg { display: none; } .floating-elements { display: none; } - .hero-root-svg { width: 300px; height: 300px; } .sites-grid { grid-template-columns: 1fr; @@ -1666,13 +1793,14 @@
      Self-Hosted Campaign Infrastructure

      Grow Power.
      Don't Rent It.

      - A deeply integrated and opinionated collection of free and open source services and production ready applications for growing political movements. Campaigns, canvassing, fundraising, team chat, and media — all on your own infrastructure. - No corporate surveillance. No foreign interference. No monthly ransoms. Free and open source. + Run your campaigns, canvassing, fundraising, team chat, media, and more — all on your own infrastructure. + No corporate surveillance. No foreign interference. No monthly ransoms. + A free and open source toolkit built for growing political movements.

      @@ -1703,7 +1831,7 @@
      Self-Hosted
    -
    30+
    +
    45+
    Integrated Tools
    @@ -1758,6 +1886,42 @@
    + +
    +
    +
    +
    +

    Software Should Grow Power, Not Extract It

    +

    Most campaign and political software is extractive by nature — designed to pull information from a community in order to influence politics. Your voter data in corporate clouds. Your strategies readable by foreign jurisdictions. Your movement’s future in someone else’s hands.

    +

    Changemaker asks a different question: “what tools are needed to grow change in a community?” Growing change means making connections between people, providing access to tools that create new opportunities, and deeply understanding the wants and needs of your movement — on infrastructure you control.

    +

    Organizational independence requires technological independence. Socialist movements will never outspend capital — but a thousand neighborhood mailing lists has more potential impact than any single organization. Workers, with the right tools, will build the future.

    +
    +
    +
    +
    🤝
    +

    Distribute Power

    +

    Decentralized organizing is the way out. When knowledge and tools are widely distributed — not gatekept by leadership or locked behind vendor paywalls — movements become resilient.

    +
    +
    +
    🔒
    +

    Own Your Secrets

    +

    If you do politics, who is reading your secrets? Corporate platforms extract intelligence systematically. Self-hosted infrastructure means your strategies stay yours — no algorithmic surveillance, no foreign data laws, no backdoors.

    +
    +
    +
    🌱
    +

    De-Corp Your Stack

    +

    Every subscription to corporate software funds the machine you’re fighting. Free and open source tools reduce dependence on capital, eliminate vendor lock-in, and keep your movement’s resources where they belong — in the community.

    +
    +
    + +
    +
    +
    + @@ -1765,7 +1929,7 @@

    The Network

    -

    30+ tools connected — each node strengthens the whole

    +

    50+ tools connected — each node strengthens the whole

    @@ -1774,7 +1938,7 @@
    📨

    Communication

    -

    Email campaigns, newsletters, advocacy, and public engagement

    +

    Email campaigns, SMS outreach, newsletters, advocacy, and team chat

    @@ -1826,6 +1990,22 @@

    Async notification queue for admin alerts and volunteer feedback. Shift reminders, session summaries, and signup confirmations.

    BullMQRemindersSummaries
    +
    +
    +
    📱
    +

    SMS Campaigns

    +
    +

    Text message outreach via Termux Android bridge. Contact lists, templated campaigns, delivery tracking, response sync, and device health monitoring.

    +
    Termux bridgeBullMQ queueResponse sync
    +
    +
    +
    +
    🗨
    +

    Chat Widget

    +
    +

    Floating Rocket.Chat panel for logged-in team members. Minimizable FAB, auth-gated access, and settings-toggleable visibility across the admin interface.

    +
    Rocket.ChatAuth-gatedFloating
    +
    @@ -1887,6 +2067,14 @@

    Import Canadian National Address Register data with province/city/postal filtering, coordinate projection, and streaming.

    2025 formatProj4Streaming
    +
    +
    +
    📆
    +

    Gancio Events

    +
    +

    Public event calendar synced from shifts via Gancio. OAuth integration, map markers for upcoming events, and embeddable GrapesJS block.

    +
    OAuth syncMap markersEmbeddable
    +
    @@ -1896,7 +2084,7 @@
    🎬

    Content & Media

    -

    Video library, page builder, documentation, and web IDE

    +

    Video, photos, playlists, page builder, documentation, and web IDE

    @@ -1905,8 +2093,8 @@
    📹

    Video Library

    -

    Upload, manage, and share videos with FFprobe metadata extraction, scheduled publishing, analytics, and a public gallery.

    -
    AnalyticsScheduling10GB uploads
    +

    Upload and manage videos with FFprobe metadata, scheduled publishing, view analytics, emoji reactions, threaded comments, and live chat.

    +
    AnalyticsLive chatScheduling
    @@ -1921,8 +2109,8 @@
    📖

    MkDocs Documentation

    -

    Material-themed documentation site with full-text search, blog, social cards, and Jinja2 template overrides.

    -
    Material themeLunr searchBlog
    +

    Material-themed docs with full-text search, blog, social cards, and Gitea-backed page comments with anonymous posting and moderation.

    +
    Material themeCommentsBlog
    @@ -1940,6 +2128,30 @@

    Collaborative diagramming and whiteboard tool. Plan canvassing routes, sketch campaign strategies, and brainstorm as a team.

    CollaborativeDiagramsReal-time
    +
    +
    +
    📷
    +

    Photo Management

    +
    +

    Album organization with bulk uploads, metadata extraction, and engagement tracking. Reactions, comments, and a public photo gallery.

    +
    AlbumsEngagementGallery
    +
    +
    +
    +
    🎵
    +

    Playlists

    +
    +

    Curated video collections with admin, user, and public playlists. Drag-reorder, sidebar navigation, featured carousel, and dedicated viewer page.

    +
    CuratedPublic/PrivateReorderable
    +
    +
    +
    +
    📲
    +

    Shorts Feed

    +
    +

    TikTok-style vertical video feed for clips under 60 seconds. Autoplay, sorting modes, and mobile-optimized swipeable interface.

    +
    Vertical videoAutoplayMobile-first
    +
    @@ -1949,7 +2161,7 @@
    📊

    Data & Automation

    -

    Database browsing, workflow automation, version control, and utilities

    +

    Database browsing, workflow automation, version control, search, and utilities

    @@ -1985,6 +2197,22 @@

    QR code generator for walk sheets, campaign materials, and event signage. Instant PNG generation.

    PNG outputEmbeddablePublic API
    +
    +
    +
    +

    Command Palette

    +
    +

    Global Ctrl+K search across pages, campaigns, locations, users, and settings. Fuzzy matching, recent items, and keyboard-driven navigation.

    +
    Ctrl+KFuzzy searchKeyboard-first
    +
    +
    +
    +
    +

    Navigation Settings

    +
    +

    Customizable public nav menu with feature toggles, custom external links, drag-reorder, and real-time preview. Control what visitors see.

    +
    Drag-reorderFeature flagsCustom links
    +
    @@ -2038,6 +2266,14 @@

    Self-hosted Bitwarden-compatible password manager. Secure credential sharing for your team with real-time sync and browser extensions.

    BitwardenTeam sharingEncrypted
    +
    +
    +
    👥
    +

    User Provisioning

    +
    +

    Automatic account sync across Rocket.Chat, Gitea, Vaultwarden, and Listmonk. Eager or lazy strategies with per-user status tracking and bulk sync.

    +
    4 servicesAuto-syncLifecycle hooks
    +
    @@ -2091,10 +2327,71 @@

    Promote donations, products, and subscriptions within the media gallery. Visibility targeting, scheduling, and click analytics.

    TargetingSchedulingCTR tracking
    +
    +
    +
    📝
    +

    Donation Pages

    +
    +

    Custom branded donation pages with configurable amounts, thank-you messages, and public slugs. Multiple campaigns with independent branding and goals.

    +
    Custom brandingSlug URLsGoals
    +
    - + +
    +
    + +
    +

    Social & Community

    +

    Friendships, activity feeds, achievements, groups, reactions, and real-time notifications

    +
    +
    +
    +
    +
    +
    👥
    +

    Friend System

    +
    +

    Friend requests, suggestions, pokes, cross-module badges on campaigns, shifts, and the map.

    +
    Friend requestsSuggestionsPoke
    +
    +
    +
    +
    📰
    +

    Activity Feed

    +
    +

    Real-time SSE feed of friend activity across campaigns, shifts, canvassing, and responses.

    +
    Real-timeSSECross-module
    +
    +
    +
    +
    🏆
    +

    Achievements & Notifications

    +
    +

    Milestone badges, real-time notification bell with friend requests, pokes, comments, and alerts.

    +
    BadgesBell UIReal-time
    +
    +
    +
    +
    👫
    +

    Groups & Teams

    +
    +

    Auto-groups for shift teams and campaign crews, custom groups, and shared updates.

    +
    Shift teamsCampaign crewsCustom groups
    +
    +
    +
    +
    😍
    +

    Reactions & Comments

    +
    +

    6 emoji reactions with floating animations, threaded comments with word-filter safety, pagination, and auto-notification.

    +
    6 emoji typesThreadedContent safety
    +
    +
    +
    + +
    🇨🇦
    @@ -2239,7 +2536,7 @@
    $0
    forever
      -
    • All 30+ campaign tools
    • +
    • All 45+ campaign tools
    • Unlimited users & data
    • Complete documentation
    • Community support
    • @@ -2340,7 +2637,7 @@

      Community

      @@ -2683,6 +2980,7 @@ { sel: '.branch[data-branch="data"]', headerSel: '.branch[data-branch="data"] .branch-header', cardSel: '.feature-node', color: '#22D3EE', angle: 0.22 }, { sel: '.branch[data-branch="devops"]', headerSel: '.branch[data-branch="devops"] .branch-header', cardSel: '.feature-node', color: '#FBBF24', angle: -0.08 }, { sel: '.branch[data-branch="fundraising"]', headerSel: '.branch[data-branch="fundraising"] .branch-header', cardSel: '.feature-node', color: '#EC4899', angle: 0.18 }, + { sel: '.branch[data-branch="social"]', headerSel: '.branch[data-branch="social"] .branch-header', cardSel: '.feature-node', color: '#38BDF8', angle: -0.18 }, { sel: '.branch[data-branch="sovereignty"]', headerSel: '.branch[data-branch="sovereignty"] .branch-header', cardSel: '.feature-node', color: '#F87171', angle: 0.08 }, { sel: '.live-sites', headerSel: '.live-sites .section-header', cardSel: '.site-card', color: '#8B5CF6', angle: -0.12 }, { sel: '#pricing', headerSel: '#pricing .section-header', cardSel: '.pricing-card, .cost-compare', color: '#C084FC', angle: 0.12 }, @@ -2696,17 +2994,31 @@ _pageH: 1, _rootY: 0, + isMobile: false, + init() { - if (window.innerWidth <= 768) return; - this.drawAll(); + this.isMobile = window.innerWidth <= 768; + this.redraw(); this.startScroll(); let resizeTimer; window.addEventListener('resize', () => { clearTimeout(resizeTimer); - resizeTimer = setTimeout(() => { this.drawAll(); this.updateScroll(); }, 300); + resizeTimer = setTimeout(() => { + this.isMobile = window.innerWidth <= 768; + this.redraw(); + this.updateScroll(); + }, 300); }); }, + redraw() { + if (this.isMobile) { + this.drawMobile(); + } else { + this.drawAll(); + } + }, + rand(seed) { const x = Math.sin(seed * 9301 + 49297) * 49297; return x - Math.floor(x); @@ -2767,6 +3079,92 @@ return c; }, + drawMobile() { + const old = document.getElementById('root-network'); + if (old) old.remove(); + this.tendrils = []; + + const rootSvgEl = document.querySelector('.hero-root-svg'); + if (!rootSvgEl) return; + + const rootRect = rootSvgEl.getBoundingClientRect(); + const rootX = rootRect.left + rootRect.width / 2; + const rootY = rootRect.top + window.scrollY + rootRect.height / 2; + + this._rootY = rootY; + this._pageH = document.documentElement.scrollHeight; + const pageW = document.documentElement.scrollWidth; + const spineX = 20; + + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('id', 'root-network'); + svg.setAttribute('class', 'root-network-svg'); + svg.setAttribute('aria-hidden', 'true'); + svg.style.height = this._pageH + 'px'; + svg.style.width = pageW + 'px'; + + // Collect junction points for each section + const junctions = []; + this.sections.forEach((sec, si) => { + const headerEl = document.querySelector(sec.headerSel); + if (!headerEl) return; + const hRect = headerEl.getBoundingClientRect(); + const jy = hRect.top + window.scrollY + hRect.height / 2; + junctions.push({ y: jy, color: sec.color, idx: si }); + }); + + if (junctions.length === 0) { document.body.appendChild(svg); return; } + + // --- Initial curve: root ball center → (spineX, first section Y) --- + const firstJ = junctions[0]; + const initD = this.wavyPath(rootX, rootY, spineX, firstJ.y, 42, 0.06); + const initPath = this.makeLine(svg, initD, '#8B5CF6', 2.0); + const initDot = this.makeDot(svg, spineX, firstJ.y, 3, firstJ.color); + this.tendrils.push({ path: initPath, dot: initDot, targetY: firstJ.y, len: 0, opacity: 0.7, isTrunk: true, sectionIdx: 0 }); + + // --- Spine segments + branch stubs --- + let prevY = firstJ.y; + for (let i = 1; i < junctions.length; i++) { + const j = junctions[i]; + const seed = 100 + i * 73; + + // Spine segment: vertical along spineX + const spineSW = this.taperSW(j.y, 1.8); + const spineD = this.wavyPath(spineX, prevY, spineX, j.y, seed, 0.03); + const spinePath = this.makeLine(svg, spineD, j.color, Math.max(spineSW, 1.0)); + const spineDot = this.makeDot(svg, spineX, j.y, 3, j.color); + this.tendrils.push({ path: spinePath, dot: spineDot, targetY: j.y, len: 0, opacity: 0.7, isTrunk: true, sectionIdx: j.idx }); + + // Branch stub: short horizontal reach to the right + const stubD = this.wavyPath(spineX, j.y, spineX + 50, j.y, seed + 37, 0.15); + const stubPath = this.makeLine(svg, stubD, j.color, 1.2); + this.tendrils.push({ path: stubPath, dot: null, targetY: j.y, len: 0, opacity: 0.5, parentY: j.y }); + + prevY = j.y; + } + + // Also add a branch stub for the first junction + const firstStubD = this.wavyPath(spineX, firstJ.y, spineX + 50, firstJ.y, 137, 0.15); + const firstStubPath = this.makeLine(svg, firstStubD, firstJ.color, 1.2); + this.tendrils.push({ path: firstStubPath, dot: null, targetY: firstJ.y, len: 0, opacity: 0.5, parentY: firstJ.y }); + + document.body.appendChild(svg); + + // Measure path lengths + set up dash animation + requestAnimationFrame(() => { + this.tendrils.forEach(t => { + try { + t.len = t.path.getTotalLength(); + t.path.style.strokeDasharray = t.len; + t.path.style.strokeDashoffset = t.len; + t.path.style.opacity = '0'; + if (t.dot) t.dot.style.opacity = '0'; + } catch(e) {} + }); + this.updateScroll(); + }); + }, + drawAll() { const old = document.getElementById('root-network'); if (old) old.remove(); diff --git a/mkdocs/site/overrides/main.html b/mkdocs/site/overrides/main.html index 240e3733..914f93fd 100644 --- a/mkdocs/site/overrides/main.html +++ b/mkdocs/site/overrides/main.html @@ -2,43 +2,63 @@ {% extends "base.html" %} {% block announce %} - + + +
      {% endblock %} diff --git a/mkdocs/site/search/search_index.json b/mkdocs/site/search/search_index.json index 1ee56eaf..a1c89886 100644 --- a/mkdocs/site/search/search_index.json +++ b/mkdocs/site/search/search_index.json @@ -1 +1 @@ -{"config":{"lang":["en"],"separator":"[\\s\\u200b\\-_,:!=\\[\\]()\"`/]+|\\.(?!\\d)|&[lg]t;|(?!\\b)(?=[A-Z][a-z])","pipeline":["stopWordFilter"],"fields":{"title":{"boost":1000.0},"text":{"boost":1.0},"tags":{"boost":1000000.0}}},"docs":[{"location":"test/","title":"Test","text":"

      Testing page.

      \ud83d\uded2 DIGITAL Test Product 1

      A test product

      $90.00

      Buy Now \u2014 $90.00

      Secure payment via Stripe. Browse all products

      View in Shop

      \u2764\ufe0f

      Support Our Work

      Every contribution makes a difference. Choose an amount below.

      $10 $25 $50 $100 Custom Make my donation anonymous Donate

      Secure payment via Stripe. Open full donate page

      Donate Now

      \u2764\ufe0f

      Support Our Work

      Every contribution makes a difference. Choose an amount below.

      $10 $25 $50 $100 Custom Amount

      \ud83d\uded2

      Browse Our Products

      Reports, toolkits, event tickets, and more.

      Shop Now Choose Your Plan

      Get access to exclusive content and features.

      View Plans

      \u2764\ufe0f

      Support Our Cause

      Your contribution helps us create lasting change in our community.

      Donate Now 9:54 Testing This Sucker 0 views Watch \u2192"},{"location":"blog/","title":"Blog","text":""},{"location":"docs/","title":"Documentation","text":"

      Welcome to the Changemaker Lite documentation. Whether you're a campaign volunteer, an admin managing operations, or a sysadmin deploying the platform \u2014 start here.

      "},{"location":"docs/#use-the-platform","title":"Use the Platform","text":"
      • Getting Started

        Install Changemaker Lite, create your first admin account, and explore the dashboard.

        Getting Started

      • Feature Guides

        Campaigns, email advocacy, response walls, map locations, landing pages, and media.

        Feature Guides

      • Administration

        User management, roles and permissions, site settings, email templates, and newsletters.

        Administration

      • Volunteer Guide

        Sign up for shifts, use the canvassing map, record visits, and track your activity.

        Volunteer Guide

      "},{"location":"docs/#deploy-operate","title":"Deploy & Operate","text":"
      • Deployment

        Docker Compose setup, environment variables, SSL/TLS, backups, and production checklist.

        Deployment

      • Architecture

        Dual API design, database schema, authentication flow, and system diagram.

        Architecture

      • Services

        Nginx routing, Redis, PostgreSQL, Listmonk, MkDocs, Gitea, NocoDB, and more.

        Services

      • Monitoring

        Prometheus metrics, Grafana dashboards, Alertmanager rules, and health checks.

        Monitoring Coming soon

      "},{"location":"docs/#reference","title":"Reference","text":"
      • API Reference

        REST endpoints for auth, campaigns, locations, shifts, media, and more.

        API Reference

      • Troubleshooting

        Common errors, CORS issues, database problems, tunnel debugging, and FAQ.

        Troubleshooting

      • Security

        Password policy, rate limiting, token rotation, encryption, and audit report.

        Security See Deployment

      • Contributing

        Development setup, code style, git workflow, and pull request guidelines.

        Contributing Coming soon

      "},{"location":"docs/#platform-at-a-glance","title":"Platform at a Glance","text":"Component Technology Purpose Main API Express.js + Prisma Auth, campaigns, map, shifts, pages, email Media API Fastify + Prisma Video library, analytics, upload, scheduling Admin GUI React + Ant Design + Zustand Dashboard for admins and organizers Database PostgreSQL 16 Single shared database for both APIs Cache Redis Rate limiting, BullMQ jobs, geocoding queue Proxy Nginx Subdomain routing, security headers, SSL Tunnel Pangolin + Newt Expose services without port forwarding Monitoring Prometheus + Grafana Metrics, dashboards, alerts

      New here?

      Start with the Getting Started guide to have the platform running in under 30 minutes.

      Looking for the source?

      Changemaker Lite is 100% open source. Browse the code on Gitea.

      "},{"location":"docs/admin/","title":"Administration","text":"

      This section covers day-to-day administration of the Changemaker Lite platform.

      Under Construction

      Detailed admin documentation is being written. Check back soon.

      "},{"location":"docs/admin/#topics","title":"Topics","text":"
      • User Management \u2014 create, edit, and deactivate user accounts
      • Roles & Permissions \u2014 SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN, USER, TEMP
      • Site Settings \u2014 configure site name, contact info, and feature flags
      • Email Templates \u2014 manage reusable email templates
      • Newsletter Sync \u2014 Listmonk integration for subscriber management
      • Email Queue \u2014 monitor and manage the BullMQ advocacy email queue
      • Landing Pages \u2014 create and publish pages with the GrapesJS editor
      "},{"location":"docs/api/","title":"API Reference","text":"

      Changemaker Lite exposes two REST APIs sharing a single PostgreSQL database.

      Server Framework Port Purpose Main API Express.js 4000 Auth, campaigns, map, shifts, canvassing, pages, email, settings Media API Fastify 4100 Video library, analytics, playlists, reactions, comments

      Both APIs use JWT Bearer authentication and return JSON. All request/response bodies are application/json unless noted otherwise.

      "},{"location":"docs/api/#authentication","title":"Authentication","text":""},{"location":"docs/api/#token-flow","title":"Token Flow","text":"
      sequenceDiagram\n    participant Client\n    participant API\n    participant DB\n\n    Client->>API: POST /api/auth/login {email, password}\n    API->>DB: Verify credentials\n    DB-->>API: User record\n    API-->>Client: {accessToken, refreshToken}\n    Note over Client: Store tokens\n\n    Client->>API: GET /api/campaigns (Authorization: Bearer <accessToken>)\n    API-->>Client: 200 OK\n\n    Note over Client: Access token expires (15 min)\n\n    Client->>API: POST /api/auth/refresh {refreshToken}\n    API->>DB: Rotate token (atomic transaction)\n    DB-->>API: New token pair\n    API-->>Client: {accessToken, refreshToken}
      "},{"location":"docs/api/#headers","title":"Headers","text":"

      All authenticated requests require:

      Authorization: Bearer <accessToken>\n

      The Media API also accepts tokens via query parameter for SSE streams:

      GET /api/public/:id/chat-stream?token=<accessToken>\n
      "},{"location":"docs/api/#roles","title":"Roles","text":"Role Access Level SUPER_ADMIN Full platform access INFLUENCE_ADMIN Campaign and advocacy management MAP_ADMIN Map, locations, shifts, canvassing USER Volunteer portal, public features TEMP Limited access (auto-created on public shift signup)"},{"location":"docs/api/#middleware-reference","title":"Middleware Reference","text":"Middleware Effect authenticate Requires valid JWT. Sets req.user with id, email, role. Returns 401 if missing or invalid. optionalAuth Same as authenticate but continues without user if token is absent. requireRole(...roles) Checks user role against allowed list. Returns 403 if not authorized. requireNonTemp Blocks TEMP users. Returns 403. validate(schema, source) Validates request body/query/params against a Zod schema. Returns 400 on failure."},{"location":"docs/api/#error-responses","title":"Error Responses","text":"

      All errors follow a consistent format:

      {\n  \"error\": {\n    \"message\": \"Human-readable error description\",\n    \"code\": \"ERROR_CODE\",\n    \"statusCode\": 400\n  }\n}\n
      Status Code Meaning 400 VALIDATION_ERROR Request body/query failed schema validation 401 UNAUTHORIZED Missing or invalid access token 403 FORBIDDEN Valid token but insufficient role 404 NOT_FOUND Resource does not exist 429 RATE_LIMITED Too many requests (see Rate Limits) 500 INTERNAL_ERROR Unexpected server error

      Enumeration Prevention

      Auth endpoints (/login, /register, /forgot-password) return generic success messages to prevent user enumeration. A 401 from /api/auth/me does not reveal whether the user exists.

      "},{"location":"docs/api/#rate-limits","title":"Rate Limits","text":"

      Rate limits are Redis-backed and keyed by IP address.

      Endpoint Group Window Max Requests Redis Prefix Auth (login, register, refresh) 15 min 10 rl:auth: Email sending 1 hour 30 rl:email: Response submission 1 hour 10 rl:response: Shift signup 1 hour 10 rl:shift-signup: Canvass visits 1 min 30 rl:canvass-visit: Canvass bulk visits 1 min 5 rl:canvass-visit-bulk: GPS tracking 1 min 6 rl:gps-tracking: Canvass geocode 1 min 10 rl:canvass-geocode: Observability 1 min 20 rl:observability: Health/metrics 1 min 30 rl:health-metrics: Global (all other) Configurable Configurable rl:global:

      When rate-limited, the API returns:

      {\n  \"error\": {\n    \"message\": \"Too many requests, please try again later\",\n    \"code\": \"RATE_LIMITED\",\n    \"statusCode\": 429\n  }\n}\n
      "},{"location":"docs/api/#main-api-express-port-4000","title":"Main API (Express \u2014 Port 4000)","text":""},{"location":"docs/api/#health-metrics","title":"Health & Metrics","text":"Method Path Auth Description GET /api/health Health check \u2014 PostgreSQL + Redis ping GET /api/metrics Prometheus metrics (text/plain) Health response
      {\n  \"status\": \"healthy\",\n  \"checks\": {\n    \"database\": \"ok\",\n    \"redis\": \"ok\"\n  }\n}\n
      "},{"location":"docs/api/#auth","title":"Auth","text":"

      Prefix: /api/auth

      Method Path Auth Rate Limited Description POST /api/auth/login Email + password login POST /api/auth/register Create account (always USER role) POST /api/auth/verify-email Verify email with token POST /api/auth/resend-verification Resend verification email POST /api/auth/forgot-password Send password reset email POST /api/auth/reset-password Set new password with reset token POST /api/auth/refresh Rotate refresh token \u2192 new token pair POST /api/auth/logout Invalidate refresh token GET /api/auth/me Current user profile Login request & response

      Request:

      {\n  \"email\": \"admin@example.com\",\n  \"password\": \"SecurePass123!\"\n}\n
      Response:
      {\n  \"accessToken\": \"eyJhbG...\",\n  \"refreshToken\": \"eyJhbG...\",\n  \"user\": {\n    \"id\": \"uuid\",\n    \"email\": \"admin@example.com\",\n    \"name\": \"Admin\",\n    \"role\": \"SUPER_ADMIN\"\n  }\n}\n

      Password Policy

      Passwords must be at least 12 characters with at least one uppercase letter, one lowercase letter, and one digit.

      "},{"location":"docs/api/#users","title":"Users","text":"

      Prefix: /api/users \u00b7 Auth: All routes require authentication

      Method Path Role Description GET /api/users Admin Paginated user list with search, role, and status filters GET /api/users/:id Admin or self Single user profile POST /api/users Admin Create user PUT /api/users/:id Admin or self Update user (non-admins cannot change role/status) POST /api/users/:id/approve Admin Approve pending user; sends approval email POST /api/users/:id/reject Admin Reject pending user DELETE /api/users/:id Admin Delete user

      Query parameters for GET /api/users:

      Param Type Description page number Page number (default 1) limit number Items per page (default 20) search string Search by name or email role string Filter by role status string Filter by status"},{"location":"docs/api/#dashboard","title":"Dashboard","text":"

      Prefix: /api/dashboard \u00b7 Auth: Admin roles required

      Method Path Role Description GET /api/dashboard/summary Any admin Platform-wide counts (users, campaigns, locations, shifts) GET /api/dashboard/system SUPER_ADMIN Hardware + OS info (CPU, memory, disk) GET /api/dashboard/containers SUPER_ADMIN Docker container statuses GET /api/dashboard/weather Any admin Current weather at map center coordinates GET /api/dashboard/api-metrics SUPER_ADMIN Prometheus API performance metrics GET /api/dashboard/time-series SUPER_ADMIN Prometheus time-series data GET /api/dashboard/container-resources SUPER_ADMIN cAdvisor CPU/memory/network per container

      Query parameters for GET /api/dashboard/time-series:

      Param Type Description metrics string Comma-separated metric keys (whitelist-validated) range string Time range (e.g., 1h, 24h, 7d) step string Sample interval (e.g., 5m, 1h)"},{"location":"docs/api/#campaigns","title":"Campaigns","text":""},{"location":"docs/api/#admin-crud","title":"Admin CRUD","text":"

      Prefix: /api/campaigns \u00b7 Auth: Admin roles

      Method Path Description GET /api/campaigns Paginated campaign list GET /api/campaigns/:id Single campaign detail POST /api/campaigns Create campaign PUT /api/campaigns/:id Update campaign DELETE /api/campaigns/:id Delete campaign"},{"location":"docs/api/#public","title":"Public","text":"Method Path Auth Description GET /api/campaigns/public List all active campaigns GET /api/campaigns/:slug/details Campaign detail by slug (ACTIVE only)"},{"location":"docs/api/#user-submissions","title":"User Submissions","text":"

      Auth: Authenticated, non-TEMP users

      Method Path Description POST /api/campaigns/user/submit Submit campaign for moderation (5/hour limit) GET /api/campaigns/user/my-campaigns List own submitted campaigns PUT /api/campaigns/user/:id Edit own pending campaign"},{"location":"docs/api/#moderation","title":"Moderation","text":"

      Auth: Admin roles

      Method Path Description GET /api/campaigns/moderation/queue Campaigns pending moderation GET /api/campaigns/moderation/stats Moderation queue statistics PATCH /api/campaigns/moderation/:id Approve or reject campaign"},{"location":"docs/api/#campaign-emails","title":"Campaign Emails","text":"Method Path Auth Description POST /api/campaigns/:slug/send-email Send advocacy email to representatives (rate limited: 30/hour) POST /api/campaigns/:slug/track-mailto Track mailto link click GET /api/campaigns/:id/emails Admin Paginated emails for campaign GET /api/campaigns/:id/email-stats Admin Email statistics"},{"location":"docs/api/#responses","title":"Responses","text":"

      Prefix: /api/campaigns (public) and /api/responses (admin + actions)

      "},{"location":"docs/api/#public_1","title":"Public","text":"Method Path Auth Description GET /api/campaigns/:slug/responses List approved public responses GET /api/campaigns/:slug/response-stats Response statistics POST /api/campaigns/:slug/responses Submit response (rate limited: 10/hour) POST /api/responses/:id/upvote Optional Upvote a response DELETE /api/responses/:id/upvote Optional Remove upvote GET /api/responses/:id/verify/:token Verify response via email link"},{"location":"docs/api/#admin","title":"Admin","text":"

      Auth: Admin roles

      Method Path Description GET /api/responses All responses with filters PATCH /api/responses/:id/status Approve or reject response POST /api/responses/:id/resend-verification Resend verification email DELETE /api/responses/:id Delete response"},{"location":"docs/api/#representatives","title":"Representatives","text":"

      Prefix: /api/representatives

      Method Path Auth Description GET /api/representatives/by-postal/:postalCode Lookup representatives by postal code (cache-first) GET /api/representatives/test-connection Represent API health check GET /api/representatives/cache-stats Admin Cache statistics GET /api/representatives Admin Paginated cached representatives GET /api/representatives/:id Admin Single cached representative DELETE /api/representatives/by-postal/:postalCode Admin Clear cache for postal code DELETE /api/representatives/:id Admin Delete cached representative

      Query parameters for postal code lookup:

      Param Type Description refresh boolean Force API call, bypass cache"},{"location":"docs/api/#email-queue","title":"Email Queue","text":"

      Prefix: /api/email-queue \u00b7 Auth: Admin roles

      Method Path Description GET /api/email-queue/stats BullMQ queue statistics (waiting, active, completed, failed) POST /api/email-queue/pause Pause email processing POST /api/email-queue/resume Resume email processing POST /api/email-queue/clean Clean completed jobs"},{"location":"docs/api/#locations","title":"Locations","text":"

      Prefix: /api/map/locations

      "},{"location":"docs/api/#public_2","title":"Public","text":"Method Path Description GET /api/map/locations/public All geocoded locations for map (no PII); optional ?bounds="},{"location":"docs/api/#admin_1","title":"Admin","text":"

      Auth: SUPER_ADMIN or MAP_ADMIN

      Method Path Description GET /api/map/locations Paginated locations with filters GET /api/map/locations/stats Location statistics GET /api/map/locations/all All geocoded locations for admin map GET /api/map/locations/export-csv CSV export GET /api/map/locations/:id Single location GET /api/map/locations/:id/history Edit history POST /api/map/locations Create location PUT /api/map/locations/:id Update location DELETE /api/map/locations/:id Delete location POST /api/map/locations/bulk-delete Bulk delete POST /api/map/locations/geocode Geocode single address POST /api/map/locations/geocode-missing Batch geocode all ungeocoded POST /api/map/locations/reverse-geocode Reverse geocode lat/lng to address POST /api/map/locations/import-csv Import from CSV (10 MB limit) POST /api/map/locations/import-bulk Bulk NAR or standard CSV import (100 MB limit)"},{"location":"docs/api/#bulk-geocode","title":"Bulk Geocode","text":"

      Prefix: /api/map/locations/bulk-geocode \u00b7 Auth: Map admins

      Method Path Description POST /api/map/locations/bulk-geocode Start BullMQ bulk geocoding job GET /api/map/locations/bulk-geocode/:jobId Poll job status GET /api/map/locations/bulk-geocode/stats Queue statistics"},{"location":"docs/api/#nar-import","title":"NAR Import","text":"

      Prefix: /api/map/nar-import \u00b7 Auth: Map admins

      Method Path Description GET /api/map/nar-import/datasets Available NAR datasets by province POST /api/map/nar-import Start province import (fire-and-forget) GET /api/map/nar-import/status/:importId Poll import progress NAR Import body
      {\n  \"provinceCode\": \"24\",\n  \"filterType\": \"city\",\n  \"filterCity\": \"Edmonton\",\n  \"residentialOnly\": true,\n  \"deduplicateRadius\": 10,\n  \"batchSize\": 500\n}\n
      "},{"location":"docs/api/#area-import","title":"Area Import","text":"

      Prefix: /api/map/area-import \u00b7 Auth: Map admins

      Method Path Description POST /api/map/area-import/preview Preview bounds + estimated record counts POST /api/map/area-import Start area import (fire-and-forget) GET /api/map/area-import/status/:importId Poll import progress"},{"location":"docs/api/#cuts-polygons","title":"Cuts (Polygons)","text":"

      Prefix: /api/map/cuts

      Method Path Auth Description GET /api/map/cuts/public All public cuts as GeoJSON GET /api/map/cuts Map admin Paginated cuts list GET /api/map/cuts/:id Map admin Single cut POST /api/map/cuts Map admin Create cut (polygon GeoJSON) PUT /api/map/cuts/:id Map admin Update cut DELETE /api/map/cuts/:id Map admin Delete cut GET /api/map/cuts/:id/locations Map admin All locations within cut polygon GET /api/map/cuts/:id/statistics Map admin Support level breakdown GET /api/map/cuts/export-geojson Map admin All cuts as GeoJSON FeatureCollection GET /api/map/cuts/:id/export-geojson Map admin Single cut as GeoJSON Feature POST /api/map/cuts/import-geojson Map admin Import cuts from GeoJSON file"},{"location":"docs/api/#shifts","title":"Shifts","text":"

      Prefix: /api/map/shifts

      "},{"location":"docs/api/#public_3","title":"Public","text":"Method Path Description GET /api/map/shifts/public List upcoming public shifts POST /api/map/shifts/public/:id/signup Public signup (creates TEMP user if needed; rate limited: 10/hour)"},{"location":"docs/api/#volunteer","title":"Volunteer","text":"

      Auth: Any authenticated user

      Method Path Description GET /api/map/shifts/volunteer/upcoming Upcoming shifts with signup status GET /api/map/shifts/volunteer/my-signups Own confirmed signups POST /api/map/shifts/volunteer/:id/signup Sign up for shift DELETE /api/map/shifts/volunteer/:id/signup Cancel signup"},{"location":"docs/api/#admin_2","title":"Admin","text":"

      Auth: Map admins

      Method Path Description GET /api/map/shifts Paginated shifts with filters GET /api/map/shifts/stats Statistics GET /api/map/shifts/calendar Calendar data (?startDate=&endDate=) GET /api/map/shifts/:id Single shift with signups POST /api/map/shifts Create shift PUT /api/map/shifts/:id Update shift DELETE /api/map/shifts/:id Delete shift POST /api/map/shifts/:id/signups Admin-add volunteer DELETE /api/map/shifts/:id/signups/:signupId Remove volunteer POST /api/map/shifts/:id/email-details Email details to all volunteers"},{"location":"docs/api/#shift-series","title":"Shift Series","text":"

      Auth: Map admins

      Method Path Description POST /api/map/shifts/series Create recurring shift series GET /api/map/shifts/series/:id Get series PUT /api/map/shifts/series/:id Update series DELETE /api/map/shifts/series/:id Delete series"},{"location":"docs/api/#canvassing","title":"Canvassing","text":"

      Prefix: /api/map/canvass

      "},{"location":"docs/api/#volunteer_1","title":"Volunteer","text":"

      Auth: Any authenticated user

      Method Path Description GET /api/map/canvass/my/assignments Shift assignments GET /api/map/canvass/my/stats Personal canvass statistics GET /api/map/canvass/my/visits Visit history GET /api/map/canvass/my/session Active canvass session POST /api/map/canvass/sessions Start canvass session POST /api/map/canvass/sessions/:id/end End session GET /api/map/canvass/cuts/:cutId/locations Locations in cut with visit annotations GET /api/map/canvass/cuts/:cutId/route Walking route algorithm for cut GET /api/map/canvass/locations All locations with visit annotations PUT /api/map/canvass/locations/:id Edit address (role-gated fields) POST /api/map/canvass/locations Create location POST /api/map/canvass/reverse-geocode Reverse geocode lat/lng POST /api/map/canvass/geocode-search Geocode address for map (rate limited: 10/min) POST /api/map/canvass/visits Record door knock (rate limited: 30/min) POST /api/map/canvass/visits/bulk Record visit for all unvisited units (rate limited: 5/min)"},{"location":"docs/api/#admin_3","title":"Admin","text":"

      Auth: SUPER_ADMIN or MAP_ADMIN

      Method Path Description GET /api/map/canvass/stats Platform-wide canvass statistics GET /api/map/canvass/stats/cuts/:cutId Statistics for specific cut GET /api/map/canvass/activity Recent activity feed GET /api/map/canvass/volunteers All volunteers with canvass activity GET /api/map/canvass/volunteers/:userId Individual volunteer statistics GET /api/map/canvass/visits All visits with filters"},{"location":"docs/api/#gps-tracking","title":"GPS Tracking","text":"

      Prefix: /api/map/tracking

      "},{"location":"docs/api/#volunteer_2","title":"Volunteer","text":"

      Auth: Any authenticated user

      Method Path Description POST /api/map/tracking/sessions Start GPS tracking session POST /api/map/tracking/sessions/:id/end End tracking session POST /api/map/tracking/sessions/:id/points Submit GPS point batch (rate limited: 6/min) POST /api/map/tracking/sessions/:id/link-canvass Link to canvass session GET /api/map/tracking/my/session Active tracking session GET /api/map/tracking/my/sessions Own historical sessions GET /api/map/tracking/my/sessions/:id/route Full route for own session"},{"location":"docs/api/#admin_4","title":"Admin","text":"

      Auth: Map admins

      Method Path Description GET /api/map/tracking/live Live volunteer positions + trails GET /api/map/tracking/sessions All historical tracking sessions GET /api/map/tracking/sessions/:id/route Full route for any session"},{"location":"docs/api/#map-settings","title":"Map Settings","text":"

      Prefix: /api/map/settings

      Method Path Auth Description GET /api/map/settings Public map settings (center, zoom, walk sheet config) PUT /api/map/settings Map admin Update map settings"},{"location":"docs/api/#geocoding","title":"Geocoding","text":"

      Prefix: /api/map/geocoding \u00b7 Auth: Map admins

      Method Path Description GET /api/map/geocoding/search Geocode address search (?q=&limit=1-10)"},{"location":"docs/api/#landing-pages","title":"Landing Pages","text":"

      Prefix: /api/pages and /api/page-blocks

      "},{"location":"docs/api/#public_4","title":"Public","text":"Method Path Auth Description GET /api/pages/:slug/view Get published page by slug"},{"location":"docs/api/#admin_5","title":"Admin","text":"

      Auth: Admin roles

      Method Path Description GET /api/pages Paginated landing pages GET /api/pages/:id Single page POST /api/pages Create page PUT /api/pages/:id Update page DELETE /api/pages/:id Delete page POST /api/pages/sync Sync MkDocs overrides from filesystem POST /api/pages/validate Validate and repair MkDocs exports"},{"location":"docs/api/#block-library","title":"Block Library","text":"

      Auth: Admin roles

      Method Path Description GET /api/page-blocks List blocks GET /api/page-blocks/:id Single block POST /api/page-blocks Create block PUT /api/page-blocks/:id Update block DELETE /api/page-blocks/:id Delete block"},{"location":"docs/api/#email-templates","title":"Email Templates","text":"

      Prefix: /api/email-templates \u00b7 Auth: Admin roles (seed/cache require SUPER_ADMIN)

      Method Path Description GET /api/email-templates List templates GET /api/email-templates/:id Single template POST /api/email-templates Create template PUT /api/email-templates/:id Update template DELETE /api/email-templates/:id Delete template GET /api/email-templates/:id/versions Version history GET /api/email-templates/:id/versions/:versionNumber Specific version POST /api/email-templates/:id/rollback Rollback to prior version POST /api/email-templates/validate Validate Handlebars syntax POST /api/email-templates/:id/test Send test email (rate limited: 10/15min) GET /api/email-templates/:id/test-logs Test send logs POST /api/email-templates/seed Seed templates from filesystem POST /api/email-templates/clear-cache Clear template cache"},{"location":"docs/api/#qr-codes","title":"QR Codes","text":"Method Path Auth Description GET /api/qr Generate QR code PNG (?text=&size=50-500)

      Cached for 1 hour. Returns image/png.

      "},{"location":"docs/api/#site-settings","title":"Site Settings","text":"

      Prefix: /api/settings

      Method Path Auth Description GET /api/settings Public site settings (SMTP credentials stripped) GET /api/settings/admin SUPER_ADMIN Full settings including SMTP credentials PUT /api/settings SUPER_ADMIN Update settings POST /api/settings/email/test-connection SUPER_ADMIN Test SMTP connection POST /api/settings/email/test-send SUPER_ADMIN Send test email"},{"location":"docs/api/#listmonk-newsletter-sync","title":"Listmonk (Newsletter Sync)","text":"

      Prefix: /api/listmonk \u00b7 Auth: SUPER_ADMIN

      Method Path Description GET /api/listmonk Sync status + connection check GET /api/listmonk/stats Subscriber counts from Listmonk POST /api/listmonk/test-connection Health check POST /api/listmonk/sync/participants Sync campaign participants POST /api/listmonk/sync/locations Sync locations POST /api/listmonk/sync/users Sync users POST /api/listmonk/sync/all Run all sync operations POST /api/listmonk/reinitialize Reinitialize Listmonk lists GET /api/listmonk/proxy-url Proxy port + JWT for iframe"},{"location":"docs/api/#documentation-management","title":"Documentation Management","text":"

      Prefix: /api/docs \u00b7 Auth: Authenticated, non-TEMP (write operations require SUPER_ADMIN)

      Method Path Description GET /api/docs/status MkDocs + Code Server availability GET /api/docs/config Port numbers for iframe URLs GET /api/docs/mkdocs-config Read raw mkdocs.yml PUT /api/docs/mkdocs-config Write mkdocs.yml POST /api/docs/build Trigger MkDocs build POST /api/docs/upload Upload asset (20 MB, whitelisted extensions) GET /api/docs/files File tree (?force=true bypasses cache) POST /api/docs/files/rename Rename or move file GET /api/docs/files/* Read file content PUT /api/docs/files/* Write file content POST /api/docs/files/* Create file or folder DELETE /api/docs/files/* Delete file or empty folder"},{"location":"docs/api/#services","title":"Services","text":"

      Prefix: /api/services \u00b7 Auth: SUPER_ADMIN

      Method Path Description GET /api/services/status Health check all managed services (NocoDB, n8n, Gitea, MailHog, Mini QR, Excalidraw, Homepage) GET /api/services/config Port numbers + subdomain info"},{"location":"docs/api/#pangolin-tunnel-management","title":"Pangolin (Tunnel Management)","text":"

      Prefix: /api/pangolin \u00b7 Auth: SUPER_ADMIN

      Method Path Description GET /api/pangolin/status Tunnel health + connection info GET /api/pangolin/config Current env configuration GET /api/pangolin/newt-status Newt container status POST /api/pangolin/newt-restart Restart Newt container GET /api/pangolin/sites List Pangolin sites GET /api/pangolin/exit-nodes Available exit nodes GET /api/pangolin/resource-definitions Resource definitions from YAML GET /api/pangolin/resources List resources POST /api/pangolin/setup Create site + all resources (rate limited: \u2157min) POST /api/pangolin/sync Sync resources (create missing, update changed) PUT /api/pangolin/resource/:id Update resource DELETE /api/pangolin/resource/:id Delete resource GET /api/pangolin/resource/:id/clients Connected clients GET /api/pangolin/certificate/:domainId/:domain Certificate info POST /api/pangolin/certificate/:certId Update certificate"},{"location":"docs/api/#observability","title":"Observability","text":"

      Prefix: /api/observability \u00b7 Auth: SUPER_ADMIN \u00b7 Rate limited: 20/min

      Method Path Description GET /api/observability/status Check 7 monitoring services GET /api/observability/metrics-summary Key metrics from Prometheus GET /api/observability/alerts Active alerts from Alertmanager"},{"location":"docs/api/#payments","title":"Payments","text":"

      Prefix: /api/payments

      "},{"location":"docs/api/#public_5","title":"Public","text":"Method Path Auth Description GET /api/payments/config Stripe publishable key + donation settings GET /api/payments/plans Active subscription plans GET /api/payments/products Active products (?type=) POST /api/payments/subscribe Create subscription checkout POST /api/payments/purchase Optional Product checkout (guest or logged-in) POST /api/payments/donate Donation checkout GET /api/payments/my-subscription Current subscription POST /api/payments/my-subscription/cancel Cancel subscription POST /api/payments/webhook Stripe webhook (raw body)"},{"location":"docs/api/#admin_6","title":"Admin","text":"

      Auth: SUPER_ADMIN

      Method Path Description GET /api/payments/admin/settings Payment settings (secrets masked) PUT /api/payments/admin/settings Update payment settings POST /api/payments/admin/settings/test-connection Test Stripe connection GET /api/payments/admin/dashboard Subscription + donation statistics GET /api/payments/admin/plans All subscription plans POST /api/payments/admin/plans Create plan PUT /api/payments/admin/plans/:id Update plan DELETE /api/payments/admin/plans/:id Delete plan POST /api/payments/admin/plans/:id/sync-stripe Sync plan to Stripe GET /api/payments/admin/subscriptions All subscriptions with filters POST /api/payments/admin/subscriptions/:id/cancel Cancel subscription GET /api/payments/admin/products All products POST /api/payments/admin/products Create product PUT /api/payments/admin/products/:id Update product DELETE /api/payments/admin/products/:id Delete product POST /api/payments/admin/products/:id/sync-stripe Sync product to Stripe GET /api/payments/admin/orders List orders POST /api/payments/admin/orders/:id/refund Refund order GET /api/payments/admin/donations List donations GET /api/payments/admin/export CSV export of completed orders"},{"location":"docs/api/#media-api-fastify-port-4100","title":"Media API (Fastify \u2014 Port 4100)","text":"

      The Media API is a separate Fastify server sharing the same PostgreSQL database. It handles all video-related functionality.

      "},{"location":"docs/api/#health","title":"Health","text":"Method Path Auth Description GET /health Media API health check"},{"location":"docs/api/#videos-admin","title":"Videos (Admin)","text":"

      Prefix: /api/videos \u00b7 Auth: Admin roles

      "},{"location":"docs/api/#crud-publishing","title":"CRUD & Publishing","text":"Method Path Description GET /api/videos List videos (?limit=&offset=&search=&orientation=&producers=&isShort=) GET /api/videos/producers Distinct producer list GET /api/videos/health Video count health check GET /api/videos/:id Single video detail PATCH /api/videos/:id Update metadata (title, producer, tags, quality, etc.) POST /api/videos/:id/publish Publish to category POST /api/videos/:id/unpublish Unpublish POST /api/videos/bulk-publish Bulk publish POST /api/videos/bulk-unpublish Bulk unpublish POST /api/videos/:id/lock Lock published video POST /api/videos/:id/unlock Unlock video POST /api/videos/:id/generate-thumbnail Generate thumbnail via FFmpeg POST /api/videos/bulk-generate-thumbnails Bulk thumbnail generation"},{"location":"docs/api/#upload","title":"Upload","text":"Method Path Description POST /api/videos/upload Single video upload (multipart, 10 GB limit, streams to disk) POST /api/videos/upload/batch Batch upload (returns 207 multi-status)"},{"location":"docs/api/#actions","title":"Actions","text":"Method Path Description POST /api/videos/:id/duplicate Duplicate video record POST /api/videos/:id/replace Replace video file, keep metadata GET /api/videos/:id/analytics Detailed analytics (?startDate=&endDate=) POST /api/videos/:id/reset-analytics Reset all analytics GET /api/videos/:id/preview-link Generate 24-hour JWT preview link GET /api/videos/analytics/top Top videos (?metric=views|watchTime&limit=) GET /api/videos/analytics/overview Global analytics overview"},{"location":"docs/api/#scheduling","title":"Scheduling","text":"Method Path Description POST /api/videos/:id/schedule-publish Schedule future publish ({publishAt, timezone?}) POST /api/videos/:id/schedule-unpublish Schedule future unpublish DELETE /api/videos/:id/schedule/:action Cancel scheduled operation GET /api/videos/schedules/upcoming Upcoming scheduled operations GET /api/videos/:id/schedule-history Schedule history for video GET /api/videos/schedules/stats Schedule queue statistics POST /api/videos/schedules/pause Pause schedule queue POST /api/videos/schedules/resume Resume schedule queue POST /api/videos/schedules/cleanup Clean old completed jobs"},{"location":"docs/api/#video-fetch","title":"Video Fetch","text":"Method Path Description POST /api/videos/fetch Submit fetch job ({urls: string[]}, 1\u201320 URLs) GET /api/videos/fetch/jobs List recent fetch jobs GET /api/videos/fetch/jobs/:jobId Job detail + log GET /api/videos/fetch/jobs/:jobId/log SSE log stream (Redis pub/sub) DELETE /api/videos/fetch/jobs/:jobId Cancel fetch job"},{"location":"docs/api/#streaming-public","title":"Streaming (Public)","text":"

      Prefix: /api/videos

      Method Path Auth Description GET /api/videos/stream/health Streaming health check GET /api/videos/:id/stream Optional HTTP range-supporting video stream GET /api/videos/:id/thumbnail Optional Serve thumbnail image GET /api/videos/:id/metadata Public video metadata for embedding

      Note

      Admins can stream unpublished videos by providing a valid JWT.

      "},{"location":"docs/api/#public-gallery","title":"Public Gallery","text":"

      Prefix: /api/public

      Method Path Auth Description GET /api/public Optional Published videos (?limit=&offset=&search=&sort=recent|popular|oldest&category=) GET /api/public/categories Optional Categories with video counts GET /api/public/producers Optional Published producers GET /api/public/:id Optional Single published video GET /api/public/:id/thumbnail Optional Published thumbnail GET /api/public/:id/stream Optional Published video stream"},{"location":"docs/api/#tracking","title":"Tracking","text":"

      Prefix: /api/track \u00b7 Auth: None required

      Method Path Description GET /api/track/health Tracking health check POST /api/track/view Record video view (returns {viewId}) POST /api/track/event Record play/pause/seek/complete event POST /api/track/heartbeat Update watch time (10s interval, sendBeacon) POST /api/track/batch Batch up to 50 tracking events Tracking is GDPR-compliant

      IP addresses are hashed with a daily-rotating salt. Raw IPs are never stored. Tracking data is retained for 90 days.

      "},{"location":"docs/api/#reactions","title":"Reactions","text":"

      Prefix: /api/reactions

      Method Path Auth Description GET /api/reactions/config Available reaction types + emoji mappings GET /api/reactions List reactions (?mediaId=&userId=&limit=) GET /api/reactions/:mediaId/chat Reactions in chat timeline format POST /api/reactions Add reaction (30s cooldown per type)

      Available types: like, love, laugh, wow, sad, angry

      "},{"location":"docs/api/#comments-chat","title":"Comments & Chat","text":""},{"location":"docs/api/#public-comments","title":"Public Comments","text":"Method Path Auth Description GET /api/public/:id/comments List comments (?limit=&offset=) POST /api/public/:id/comments Optional Create comment (word-filtered; rate limited: 5/min) GET /api/public/:id/chat-stream SSE stream for real-time chat (30s keepalive)"},{"location":"docs/api/#comment-admin","title":"Comment Admin","text":"

      Prefix: /api/media/admin/comments \u00b7 Auth: Admin roles

      Method Path Description GET /api/media/admin/comments/stats Counts by status GET /api/media/admin/comments All comments with filters PATCH /api/media/admin/comments/:id/approve Approve comment PATCH /api/media/admin/comments/:id/hide Hide comment PATCH /api/media/admin/comments/:id/unhide Unhide comment PUT /api/media/admin/comments/:id/notes Update moderation notes DELETE /api/media/admin/comments/:id Delete comment"},{"location":"docs/api/#word-filters","title":"Word Filters","text":"

      Prefix: /api/media/admin/word-filters \u00b7 Auth: Admin roles

      Method Path Description GET /api/media/admin/word-filters List filter entries grouped by level POST /api/media/admin/word-filters Add word ({word, level: low|medium|high|custom}) DELETE /api/media/admin/word-filters/:id Remove word"},{"location":"docs/api/#chat-threads-notifications","title":"Chat Threads & Notifications","text":"

      Auth: Authenticated

      Method Path Description GET /api/media/chat/threads Videos with user's comments + unread counts POST /api/media/chat/threads/:mediaId/read Mark thread as read GET /api/media/notifications/stream Per-user SSE notification stream (?token=)"},{"location":"docs/api/#shorts","title":"Shorts","text":"Method Path Auth Description GET /api/shorts Optional Shorts feed (?sort=recent|popular|random) POST /api/shorts/scan Admin Auto-classify short videos by duration"},{"location":"docs/api/#upvotes","title":"Upvotes","text":"Method Path Auth Description POST /api/public/:id/upvote Toggle upvote (session-based via X-Session-ID header) GET /api/public/:id/upvote-status Check upvote status for current session"},{"location":"docs/api/#playlists","title":"Playlists","text":""},{"location":"docs/api/#public_6","title":"Public","text":"

      Prefix: /api/playlists

      Method Path Auth Description GET /api/playlists/featured Optional Featured playlists GET /api/playlists/popular Optional Popular public playlists (?search=) GET /api/playlists/share/:token Optional Playlist by share token GET /api/playlists/:id Optional Playlist detail (public, owner, or share token) POST /api/playlists/:id/view Optional Record playlist view"},{"location":"docs/api/#user-playlists","title":"User Playlists","text":"

      Auth: Authenticated

      Method Path Description GET /api/playlists/my Own playlists POST /api/playlists Create playlist PUT /api/playlists/:id Update playlist (ownership check) DELETE /api/playlists/:id Delete playlist POST /api/playlists/:id/videos Add video ({mediaId}) DELETE /api/playlists/:id/videos/:mediaId Remove video PUT /api/playlists/:id/videos/reorder Reorder videos POST /api/playlists/:id/share Generate share token DELETE /api/playlists/:id/share Revoke share token"},{"location":"docs/api/#playlist-admin","title":"Playlist Admin","text":"

      Prefix: /api/media/playlists \u00b7 Auth: Admin roles

      Method Path Description GET /api/media/playlists All playlists GET /api/media/playlists/featured Featured playlists with admin info POST /api/media/playlists/:id/feature Feature a playlist DELETE /api/media/playlists/:id/feature Unfeature a playlist PUT /api/media/playlists/featured/reorder Reorder featured playlists PUT /api/media/playlists/:id Admin update any playlist POST /api/media/playlists/:id/duplicate Duplicate playlist DELETE /api/media/playlists/:id Admin delete any playlist"},{"location":"docs/api/#user-profile","title":"User Profile","text":"

      Prefix: /api/media/me \u00b7 Auth: Authenticated

      Method Path Description GET /api/media/me/stats User stats + 30-day activity + achievements GET /api/media/me/watch-history Paginated watch history POST /api/media/me/stats/recalculate Recompute stats from raw data GET /api/media/me/settings Privacy settings PUT /api/media/me/settings Update privacy settings PUT /api/media/me/profile Update display name PUT /api/media/me/password Change password"},{"location":"docs/api/#route-summary","title":"Route Summary","text":"API Module Endpoint Count Express Auth 9 Users 7 Dashboard 7 Campaigns (CRUD + public + user + moderation + emails) 16 Responses 10 Email Queue 4 Representatives 7 Locations (CRUD + geocode + import) 21 Cuts 11 Shifts (CRUD + series) 19 Canvassing 20 GPS Tracking 10 Map Settings + Geocoding 3 Pages + Blocks 12 Email Templates 13 QR Codes 1 Site Settings 5 Listmonk 9 Docs Management 11 Services 2 Pangolin 16 Observability 3 Payments (public + admin) 29 Health + Metrics 3 Express Total ~248 Fastify Videos (CRUD + upload + actions + schedule + fetch) 39 Streaming 4 Public Gallery 6 Tracking 5 Reactions 4 Comments + Chat 13 Shorts + Upvotes 4 Playlists (public + user + admin) 18 User Profile 7 Health 1 Fastify Total ~101 Grand Total ~349"},{"location":"docs/architecture/","title":"Architecture","text":"

      Changemaker Lite uses a dual-API architecture with a shared PostgreSQL database.

      Under Construction

      Detailed architecture documentation is being written. Check back soon.

      "},{"location":"docs/architecture/#system-overview","title":"System Overview","text":"
      Browser \u2500\u2500\u25ba Nginx (reverse proxy) \u2500\u2500\u252c\u2500\u2500\u25ba Express API (port 4000) \u2500\u2500\u25ba PostgreSQL\n                                    \u251c\u2500\u2500\u25ba Fastify Media API (port 4100) \u2500\u2500\u2518\n                                    \u251c\u2500\u2500\u25ba React Admin GUI (port 3000)\n                                    \u2514\u2500\u2500\u25ba MkDocs / Other Services\n
      "},{"location":"docs/architecture/#key-components","title":"Key Components","text":"Component Technology Role Main API Express.js + Prisma Auth, campaigns, map, shifts, pages Media API Fastify + Prisma Video library, analytics, uploads Admin GUI React + Ant Design Single-page admin application Database PostgreSQL 16 Shared by both APIs (30+ tables) Cache Redis Rate limiting, job queues, geocoding Proxy Nginx Subdomain routing, security headers"},{"location":"docs/architecture/#authentication-flow","title":"Authentication Flow","text":"
      • JWT access tokens (15 min) + refresh tokens (7 days)
      • Refresh token rotation with atomic database transaction
      • Role-based access control (5 roles)
      • Rate limiting on auth endpoints (10/min per IP)
      "},{"location":"docs/deployment/","title":"Deployment","text":"

      This guide covers how to take Changemaker Lite from a local development setup to a publicly accessible production deployment. The main decision is how to expose your services to the internet.

      "},{"location":"docs/deployment/#architecture-overview","title":"Architecture Overview","text":"

      Regardless of which exposure method you choose, the internal architecture is the same:

      Internet \u2192 [Your exposure method] \u2192 Nginx (port 80) \u2192 Backend Services\n

      Nginx handles all subdomain routing internally. Every service is accessed through nginx on port 80, which proxies to the correct container based on the Host header.

      Subdomain Service Container Port app.DOMAIN Admin GUI + public pages 3000 api.DOMAIN Express API 4000 media.DOMAIN Fastify Media API 4100 DOMAIN (root) MkDocs documentation site 4001 db.DOMAIN NocoDB 8091 docs.DOMAIN MkDocs live preview 4003 code.DOMAIN Code Server 8888 git.DOMAIN Gitea 3030 n8n.DOMAIN Workflow automation 5678 home.DOMAIN Homepage dashboard 3010 listmonk.DOMAIN Newsletter manager 9001 mail.DOMAIN MailHog (dev email) 8025 qr.DOMAIN Mini QR generator 8089 draw.DOMAIN Excalidraw whiteboard 8090 grafana.DOMAIN Monitoring dashboards 3001"},{"location":"docs/deployment/#exposure-methods","title":"Exposure Methods","text":""},{"location":"docs/deployment/#pangolin","title":"Option 1: Pangolin + Newt Tunnel (Recommended)","text":"

      Admin GUI: Tunnel Management Page

      The admin dashboard includes a dedicated Tunnel Management page at Admin \u2192 Settings \u2192 Tunnel. This page provides:

      • Live status of the Pangolin connection and Newt container health
      • Step-by-step setup instructions if credentials aren't configured yet
      • Full resource table listing every service, its domain, and target \u2014 useful as a reference when creating resources in the Pangolin dashboard
      • API-based site creation as an alternative to the Pangolin dashboard UI
      • Restart Newt button for quick container restarts without the terminal

      If you're unsure about any step above, the Tunnel page walks you through the same process interactively.

      Pangolin is a self-hosted tunnel server. The Newt client container runs alongside your stack and establishes an outbound connection to your Pangolin server, which then routes public traffic back through the tunnel. No port forwarding or static IP required.

      Advantages:

      • No port forwarding needed on your router/firewall
      • Works behind CGNAT, double NAT, or restrictive networks
      • SSL/TLS handled by the Pangolin server
      • Self-hosted \u2014 you control the tunnel infrastructure
      • Built-in access control (optional per-resource authentication)

      Requirements:

      • A Pangolin server (self-hosted on a VPS with a public IP)
      • A domain with DNS pointing to the Pangolin server
      • Pangolin API key and organization ID
      "},{"location":"docs/deployment/#step-1-configure-pangolin-credentials","title":"Step 1: Configure Pangolin Credentials","text":"

      If you used config.sh, you may have already set these. Otherwise, add to your .env:

      PANGOLIN_API_URL=https://api.your-pangolin-server.org/v1\nPANGOLIN_API_KEY=your_api_key_here\nPANGOLIN_ORG_ID=your_org_id\n
      "},{"location":"docs/deployment/#step-2-create-a-site-in-pangolin","title":"Step 2: Create a Site in Pangolin","text":"

      Log in to your Pangolin dashboard and create a new site:

      1. Navigate to Sites \u2192 Create New Site
      2. Choose type: Newt
      3. Enter a name (e.g., changemaker-yourdomain.org)
      4. Choose a subnet (e.g., 100.90.128.3/24)
      5. Select an exit node (if applicable)
      6. Click Create Site
      7. Copy the credentials \u2014 you'll need the Site ID, Newt ID, and Newt Secret

      Save the credentials

      The Newt Secret is only shown once during site creation. Copy it immediately.

      "},{"location":"docs/deployment/#step-3-update-env-with-site-credentials","title":"Step 3: Update .env with Site Credentials","text":"
      PANGOLIN_SITE_ID=your_site_id\nPANGOLIN_ENDPOINT=https://your-pangolin-server.org\nPANGOLIN_NEWT_ID=your_newt_id\nPANGOLIN_NEWT_SECRET=your_newt_secret\n
      "},{"location":"docs/deployment/#step-4-start-the-newt-container","title":"Step 4: Start the Newt Container","text":"
      docker compose up -d newt\n

      The Newt container connects to nginx (its only dependency) and establishes the tunnel:

      # From docker-compose.yml\nnewt:\n  image: fosrl/newt\n  container_name: newt-changemaker\n  restart: unless-stopped\n  environment:\n    - PANGOLIN_ENDPOINT=${PANGOLIN_ENDPOINT}\n    - NEWT_ID=${PANGOLIN_NEWT_ID}\n    - NEWT_SECRET=${PANGOLIN_NEWT_SECRET}\n  depends_on:\n    - nginx\n

      Verify the connection:

      docker compose logs newt --tail 20\n

      You should see a successful connection message.

      "},{"location":"docs/deployment/#step-5-create-public-http-resources","title":"Step 5: Create Public HTTP Resources","text":"

      In the Pangolin dashboard, create an HTTP resource for each service you want exposed. All resources point to nginx:80 \u2014 nginx handles the routing internally.

      Required resources (minimum for a working deployment):

      Resource Name Domain Target Auth Admin GUI app.yourdomain.org nginx:80 Not Protected API Server api.yourdomain.org nginx:80 Not Protected Public Site yourdomain.org nginx:80 Not Protected

      Optional resources (add as needed):

      Resource Name Domain Target Media API media.yourdomain.org nginx:80 NocoDB db.yourdomain.org nginx:80 Documentation docs.yourdomain.org nginx:80 Code Server code.yourdomain.org nginx:80 Gitea git.yourdomain.org nginx:80 Grafana grafana.yourdomain.org nginx:80

      Set resources to Not Protected

      By default, Pangolin may enable authentication on new resources. This causes 302 redirects to the Pangolin login page instead of reaching your services. Set each resource to Not Protected (public access) unless you intentionally want Pangolin SSO in front of it.

      "},{"location":"docs/deployment/#step-6-update-cors-for-production","title":"Step 6: Update CORS for Production","text":"

      Add your production domain to CORS_ORIGINS in .env:

      CORS_ORIGINS=https://app.yourdomain.org,http://localhost:3000,http://localhost\n

      Then restart the API:

      docker compose restart api\n
      "},{"location":"docs/deployment/#step-7-verify","title":"Step 7: Verify","text":"
      # Should return JSON (not a 302 redirect)\ncurl https://api.yourdomain.org/api/health\n\n# Admin GUI should load\ncurl -I https://app.yourdomain.org\n
      "},{"location":"docs/deployment/#cloudflare","title":"Option 2: Cloudflare Tunnel","text":"

      Cloudflare Tunnel (cloudflared) provides a similar zero-trust tunnel approach using Cloudflare's network. No port forwarding needed, and you get Cloudflare's CDN and DDoS protection.

      Advantages:

      • Free tier available
      • Built-in CDN and DDoS protection
      • No port forwarding needed
      • Managed SSL certificates

      Disadvantages:

      • Proprietary service (not self-hosted)
      • Cloudflare sees all traffic (no end-to-end encryption to your origin)
      • Subject to Cloudflare's Terms of Service
      "},{"location":"docs/deployment/#setup","title":"Setup","text":"
      1. Create a Cloudflare Tunnel in the Zero Trust dashboard

      2. Add a cloudflared service to your docker-compose.yml:

        cloudflared:\n  image: cloudflare/cloudflared:latest\n  container_name: cloudflared-changemaker\n  restart: unless-stopped\n  command: tunnel run\n  environment:\n    - TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN}\n  depends_on:\n    - nginx\n  networks:\n    - changemaker-lite\n
      3. Add your tunnel token to .env:

        CLOUDFLARE_TUNNEL_TOKEN=your_tunnel_token_here\n
      4. Configure public hostnames in the Cloudflare dashboard, all pointing to http://nginx:80:

        Hostname Service app.yourdomain.org http://nginx:80 api.yourdomain.org http://nginx:80 yourdomain.org http://nginx:80 (add more as needed) http://nginx:80
      5. Start the tunnel:

        docker compose up -d cloudflared\n

      Note

      The cloudflared service is not included in the default docker-compose.yml. Add it manually if you choose this method. The Newt service can be removed or left stopped.

      "},{"location":"docs/deployment/#direct","title":"Option 3: Direct DNS + Reverse Proxy","text":"

      If your server has a public IP address (e.g., a VPS or dedicated server), you can point DNS directly to it and use nginx with SSL certificates.

      Advantages:

      • No tunnel overhead or third-party dependency
      • Full control over the network path
      • Lowest latency

      Disadvantages:

      • Requires a public IP and open ports (80, 443)
      • You manage SSL certificates yourself
      • Server IP is exposed
      "},{"location":"docs/deployment/#setup_1","title":"Setup","text":"
      1. Point DNS for your domain and all subdomains to your server's IP:

        A     yourdomain.org        \u2192 YOUR_SERVER_IP\nA     *.yourdomain.org      \u2192 YOUR_SERVER_IP\n

        Or use individual A records for each subdomain if your DNS provider doesn't support wildcards.

      2. Open ports 80 and 443 on your server's firewall.

      3. Install Certbot (or another ACME client) for SSL certificates:

        # Ubuntu/Debian\nsudo apt install certbot\n\n# Get a wildcard certificate with DNS challenge\nsudo certbot certonly --manual --preferred-challenges dns \\\n  -d yourdomain.org -d '*.yourdomain.org'\n

        Alternatively, use the Certbot Docker image or a Let's Encrypt companion container.

      4. Update nginx to listen on 443 with your certificates. Add an SSL server block to nginx/conf.d/ssl.conf:

        server {\n    listen 443 ssl;\n    server_name app.yourdomain.org;\n\n    ssl_certificate /etc/nginx/ssl/fullchain.pem;\n    ssl_certificate_key /etc/nginx/ssl/privkey.pem;\n\n    location / {\n        proxy_pass http://changemaker-v2-admin:3000;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n        proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection \"upgrade\";\n    }\n}\n\n# Repeat for api.yourdomain.org, media.yourdomain.org, etc.\n# Or use a single server block with $host matching\n
      5. Mount certificates into the nginx container via docker-compose.yml:

        nginx:\n  volumes:\n    - /etc/letsencrypt/live/yourdomain.org:/etc/nginx/ssl:ro\n
      6. Set up auto-renewal with a cron job or systemd timer:

        0 3 * * * certbot renew --quiet && docker compose restart nginx\n

      Traefik alternative

      If you prefer automatic SSL and don't want to manage nginx SSL config manually, consider replacing nginx with Traefik. Traefik can auto-discover Docker containers and provision Let's Encrypt certificates automatically. This would require adapting the container labels and removing the nginx service.

      "},{"location":"docs/deployment/#tailscale","title":"Option 4: Tailscale / WireGuard (Private Access)","text":"

      For deployments that should only be accessible to specific people (not the general public), a mesh VPN like Tailscale or plain WireGuard gives you private networking without exposing anything to the internet.

      Use cases:

      • Internal team deployments
      • Development/staging servers
      • Access from mobile devices without public exposure
      "},{"location":"docs/deployment/#tailscale-setup","title":"Tailscale Setup","text":"
      1. Install Tailscale on your server and client devices
      2. Access services via Tailscale IP (e.g., http://100.x.x.x:3000)
      3. Optionally use Tailscale Funnel to selectively expose specific services publicly
      "},{"location":"docs/deployment/#wireguard-setup","title":"WireGuard Setup","text":"
      1. Set up a WireGuard server on your host
      2. Connect client devices via WireGuard config
      3. Access services via the WireGuard interface IP

      Note

      With private access methods, you may not need subdomain routing at all. Access services directly by port: http://server-ip:3000 (admin), http://server-ip:4000 (API), etc.

      "},{"location":"docs/deployment/#production-checklist","title":"Production Checklist","text":"

      Before going live, verify each item:

      "},{"location":"docs/deployment/#security","title":"Security","text":"
      • All placeholder passwords changed (grep -c \"REQUIRED_STRONG\" .env should return 0)
      • NODE_ENV=production set in .env
      • ENCRYPTION_KEY set and differs from JWT secrets
      • EMAIL_TEST_MODE=false (unless you want MailHog in production)
      • CORS_ORIGINS includes your production domain
      • Admin password changed after first login
      • Redis password set (REDIS_PASSWORD)
      "},{"location":"docs/deployment/#networking","title":"Networking","text":"
      • DNS records configured for your domain and subdomains
      • SSL/TLS working (tunnel handles this, or manual certs)
      • All Pangolin resources set to \"Not Protected\" (if using Pangolin)
      • curl https://api.yourdomain.org/api/health returns JSON
      "},{"location":"docs/deployment/#services","title":"Services","text":"
      • Core services running: docker compose ps shows api, admin, v2-postgres, redis, nginx healthy
      • Database migrated: docker compose exec api npx prisma migrate deploy
      • Database seeded: docker compose exec api npx prisma db seed
      • Admin GUI accessible at https://app.yourdomain.org
      "},{"location":"docs/deployment/#backups","title":"Backups","text":"
      • Backup script tested: ./scripts/backup.sh
      • Backup cron job configured (see Backups below)
      • Restore procedure tested at least once
      "},{"location":"docs/deployment/#monitoring-optional","title":"Monitoring (Optional)","text":"
      • Monitoring stack started: docker compose --profile monitoring up -d
      • Grafana accessible and dashboards loading
      • Alert rules configured in Alertmanager
      "},{"location":"docs/deployment/#backups_1","title":"Backups","text":"

      The included backup script dumps PostgreSQL databases, archives uploads, and optionally uploads to S3.

      "},{"location":"docs/deployment/#running-a-backup","title":"Running a Backup","text":"
      ./scripts/backup.sh\n

      This creates a timestamped directory under ./backups/ containing:

      • changemaker_v2.sql.gz \u2014 Main PostgreSQL dump (compressed)
      • listmonk.sql.gz \u2014 Listmonk database dump (if running)
      • uploads.tar.gz \u2014 Media uploads archive
      • manifest.json \u2014 Backup metadata
      "},{"location":"docs/deployment/#options","title":"Options","text":"
      # Upload to S3 (requires AWS CLI + S3_BUCKET env var)\n./scripts/backup.sh --s3\n\n# Custom retention (delete local backups older than N days)\n./scripts/backup.sh --retention 14\n
      "},{"location":"docs/deployment/#automated-backups","title":"Automated Backups","text":"

      Add a cron job for daily backups:

      # Edit crontab\ncrontab -e\n\n# Add daily backup at 3 AM\n0 3 * * * /path/to/changemaker.lite/scripts/backup.sh >> /var/log/changemaker-backup.log 2>&1\n\n# With S3 upload\n0 3 * * * /path/to/changemaker.lite/scripts/backup.sh --s3 >> /var/log/changemaker-backup.log 2>&1\n
      "},{"location":"docs/deployment/#restore","title":"Restore","text":"
      # Restore main database\ngunzip -c backups/changemaker-v2-backup-TIMESTAMP/changemaker_v2.sql.gz | \\\n  docker compose exec -T v2-postgres psql -U changemaker changemaker_v2\n\n# Restore Listmonk database\ngunzip -c backups/changemaker-v2-backup-TIMESTAMP/listmonk.sql.gz | \\\n  docker compose exec -T listmonk-db psql -U listmonk listmonk\n\n# Restore uploads\ntar xzf backups/changemaker-v2-backup-TIMESTAMP/uploads.tar.gz -C ./\n
      "},{"location":"docs/deployment/#monitoring","title":"Monitoring","text":"

      The monitoring stack runs behind a Docker Compose profile and is not started by default.

      "},{"location":"docs/deployment/#starting-the-monitoring-stack","title":"Starting the Monitoring Stack","text":"
      docker compose --profile monitoring up -d\n

      This starts:

      Service Port Purpose Prometheus 9090 Metrics collection and queries Grafana 3001 Dashboards and visualization Alertmanager 9093 Alert routing and notifications cAdvisor 8080 Container resource metrics Node Exporter 9100 Host system metrics Redis Exporter 9121 Redis metrics Gotify 8889 Push notifications"},{"location":"docs/deployment/#pre-configured-dashboards","title":"Pre-configured Dashboards","text":"

      Grafana includes 3 auto-provisioned dashboards:

      1. API Overview \u2014 HTTP request rates, latency, error rates, active sessions
      2. Infrastructure \u2014 Container CPU/memory, PostgreSQL connections, Redis memory
      3. Campaign Activity \u2014 Email queue size, campaign sends, response submissions
      "},{"location":"docs/deployment/#custom-metrics","title":"Custom Metrics","text":"

      The API exposes 12 custom Prometheus metrics with the cm_ prefix:

      • cm_api_uptime_seconds \u2014 API uptime
      • cm_email_queue_size \u2014 BullMQ pending emails
      • cm_active_canvass_sessions \u2014 Active canvassing sessions
      • cm_locations_total \u2014 Total locations in database
      • And more \u2014 see api/src/utils/metrics.ts
      "},{"location":"docs/deployment/#alert-rules","title":"Alert Rules","text":"

      Pre-configured alerts in configs/prometheus/alerts.yml:

      • API down for more than 5 minutes
      • High error rate (>5% of requests returning 5xx)
      • Database connection failures
      • Redis connection failures
      • Email queue backlog
      • Disk space warnings
      "},{"location":"docs/deployment/#upgrading","title":"Upgrading","text":""},{"location":"docs/deployment/#pulling-updates","title":"Pulling Updates","text":"
      # Pull latest code\ngit pull origin v2\n\n# Rebuild and restart containers\ndocker compose build api admin\ndocker compose up -d api admin\n\n# Run any new migrations\ndocker compose exec api npx prisma migrate deploy\n
      "},{"location":"docs/deployment/#database-migrations","title":"Database Migrations","text":"

      Always run migrations after pulling updates:

      docker compose exec api npx prisma migrate deploy\n

      Back up first

      Always run ./scripts/backup.sh before applying migrations in production. Migrations may alter table structures and are not easily reversible.

      "},{"location":"docs/deployment/#troubleshooting-production-issues","title":"Troubleshooting Production Issues","text":""},{"location":"docs/deployment/#pangolin-302-redirects-instead-of-content","title":"Pangolin: 302 Redirects Instead of Content","text":"

      Symptom: API returns 302 redirects to the Pangolin authentication page.

      Fix: In the Pangolin dashboard, edit each resource and set Authentication to Not Protected.

      "},{"location":"docs/deployment/#cors-errors","title":"CORS Errors","text":"

      Symptom: Browser console shows CORS errors when accessing the production domain.

      Fix: Add your production app. subdomain to CORS_ORIGINS in .env, then docker compose restart api.

      "},{"location":"docs/deployment/#newt-wont-connect","title":"Newt Won't Connect","text":"

      Check in order:

      1. Credentials: Verify PANGOLIN_NEWT_ID and PANGOLIN_NEWT_SECRET in .env
      2. Endpoint: Confirm PANGOLIN_ENDPOINT matches your Pangolin server URL
      3. Logs: docker compose logs newt --tail 50
      4. Nginx running: Newt depends on nginx \u2014 docker compose ps nginx
      5. Network: Ensure outbound HTTPS is not blocked by your firewall
      "},{"location":"docs/deployment/#services-unreachable-via-tunnel","title":"Services Unreachable via Tunnel","text":"
      1. Verify nginx is running: docker compose ps nginx
      2. Test locally first: curl http://localhost:4000/api/health
      3. Check nginx logs: docker compose logs nginx --tail 50
      4. Verify DNS: dig app.yourdomain.org should point to your Pangolin server

      See Troubleshooting for more common issues.

      "},{"location":"docs/features/","title":"Feature Guides","text":"

      Changemaker Lite bundles advocacy campaigns, geographic mapping, volunteer management, media hosting, and landing pages into a single self-hosted platform. Every feature can be toggled on or off from Settings in the admin panel.

      "},{"location":"docs/features/#advocacy-campaigns","title":"Advocacy Campaigns","text":"

      Help supporters contact their elected representatives through email campaigns.

      "},{"location":"docs/features/#how-it-works","title":"How It Works","text":"
      1. An admin creates a campaign \u2014 writes the email subject and body, selects which government levels to target (federal, provincial, municipal, school board), and publishes it.
      2. A supporter visits the campaign page \u2014 enters their postal code to look up their representatives.
      3. The supporter sends the email \u2014 either directly through the platform (\"Send Now\") or by opening it in their own email app (Gmail, Outlook, etc.).
      4. Responses get tracked \u2014 supporters and admins can share representative responses on the Response Wall, with upvoting and moderation.
      "},{"location":"docs/features/#key-features","title":"Key Features","text":"
      • Postal code lookup \u2014 powered by the Represent API, returns representatives at all government levels
      • Two send methods \u2014 server-sent SMTP (tracked) or mailto link (opens user's email app)
      • Email editing \u2014 optionally let supporters personalize the email before sending
      • Response Wall \u2014 public wall where people share how their representatives responded, with moderation and verification
      • Campaign stats \u2014 track emails sent, responses received, and upvotes
      • Featured campaigns \u2014 highlight important campaigns on the public listing page
      "},{"location":"docs/features/#admin-routes","title":"Admin Routes","text":"
      • /app/campaigns \u2014 create, edit, and manage campaigns
      • /app/responses \u2014 moderate submitted responses
      • /app/email-queue \u2014 monitor outgoing email delivery
      "},{"location":"docs/features/#public-routes","title":"Public Routes","text":"
      • /campaigns \u2014 browse active campaigns
      • /campaign/:slug \u2014 take action on a specific campaign
      • /campaign/:slug/responses \u2014 view the response wall
      "},{"location":"docs/features/#map-canvassing","title":"Map & Canvassing","text":"

      Manage locations, organize canvassing territories, and coordinate volunteer door-to-door outreach.

      "},{"location":"docs/features/#locations","title":"Locations","text":"

      Import addresses via CSV or the Canadian NAR (National Address Register) dataset. Each location can be geocoded using multiple providers (Nominatim, ArcGIS, Photon, Mapbox, and more). Locations appear as color-coded markers on admin and public maps.

      "},{"location":"docs/features/#areas-cuts","title":"Areas (Cuts)","text":"

      Draw polygon regions on the map to define canvassing territories. Areas help organize locations into manageable chunks for volunteers. Each area shows stats like total addresses, visit counts, and coverage percentage.

      "},{"location":"docs/features/#shifts","title":"Shifts","text":"

      Schedule volunteer time slots and let people sign up through a public page. Shifts can be linked to specific areas so volunteers know where they'll be canvassing.

      "},{"location":"docs/features/#canvassing","title":"Canvassing","text":"

      The volunteer canvass map is a full-screen GPS-tracked experience:

      • Walking routes \u2014 the system generates an efficient route through assigned locations
      • Visit recording \u2014 tap a marker, record the outcome (not home, supportive, opposed, etc.)
      • GPS tracking \u2014 real-time position shown on the map
      • Cluster markers \u2014 addresses grouped intelligently for performance at scale
      "},{"location":"docs/features/#admin-routes_1","title":"Admin Routes","text":"
      • /app/map \u2014 manage locations (CRUD, CSV import/export, geocoding)
      • /app/map/cuts \u2014 draw and manage canvassing areas
      • /app/map/shifts \u2014 schedule shifts and view signups
      • /app/map/canvass \u2014 canvass dashboard (stats, activity feed, leaderboard)
      "},{"location":"docs/features/#public-routes_1","title":"Public Routes","text":"
      • /map \u2014 public map view (address locations only, no canvass data)
      • /shifts \u2014 sign up for volunteer shifts
      "},{"location":"docs/features/#volunteer-routes","title":"Volunteer Routes","text":"
      • /volunteer \u2014 full-screen canvass map with GPS and visit recording
      • /volunteer/shifts \u2014 view assigned shifts
      • /volunteer/activity \u2014 visit history and outcome breakdown
      • /volunteer/routes \u2014 past canvassing routes
      "},{"location":"docs/features/#media-manager","title":"Media Manager","text":"

      Upload, organize, and share campaign videos with built-in analytics.

      "},{"location":"docs/features/#key-features_1","title":"Key Features","text":"
      • Video upload \u2014 drag-and-drop upload with automatic metadata extraction (duration, dimensions, quality)
      • Public gallery \u2014 shareable video gallery at /gallery with category browsing
      • Analytics \u2014 view counts, watch time, completion rates, and traffic sources (GDPR-compliant)
      • Scheduled publishing \u2014 automate publish/unpublish with timezone support and a calendar view
      • Curated playlists \u2014 organize videos into shareable collections
      • Quick actions \u2014 keyboard shortcuts (E/P/A/S) for edit, preview, analytics, schedule
      "},{"location":"docs/features/#admin-routes_2","title":"Admin Routes","text":"
      • /app/media/library \u2014 upload and manage videos
      • /app/media/curated \u2014 manage playlists
      • /app/media/jobs \u2014 monitor processing jobs
      "},{"location":"docs/features/#public-routes_2","title":"Public Routes","text":"
      • /gallery \u2014 browse the public video gallery
      • /gallery/watch/:id \u2014 watch a specific video
      "},{"location":"docs/features/#landing-pages","title":"Landing Pages","text":"

      Build campaign microsites with a drag-and-drop visual editor.

      "},{"location":"docs/features/#how-it-works_1","title":"How It Works","text":"
      1. Create a new page from the admin panel
      2. Open the GrapesJS visual editor \u2014 drag blocks, edit text, adjust styles
      3. Save and publish \u2014 the page goes live at /p/:slug
      4. Optionally export to MkDocs for inclusion in the documentation site
      "},{"location":"docs/features/#admin-routes_3","title":"Admin Routes","text":"
      • /app/pages \u2014 list and manage landing pages
      • /app/pages/:id/edit \u2014 full-screen GrapesJS editor
      "},{"location":"docs/features/#public-routes_3","title":"Public Routes","text":"
      • /p/:slug \u2014 view a published landing page
      "},{"location":"docs/features/#newsletter-listmonk","title":"Newsletter (Listmonk)","text":"

      Integrated with Listmonk for opt-in mailing lists and newsletter campaigns.

      "},{"location":"docs/features/#sync","title":"Sync","text":"

      When enabled (LISTMONK_SYNC_ENABLED=true), the platform syncs shift participants, location contacts, and user accounts to Listmonk subscriber lists. Sync is triggered manually from the admin panel.

      "},{"location":"docs/features/#admin-routes_4","title":"Admin Routes","text":"
      • /app/listmonk (sidebar: \"Newsletter\") \u2014 sync status, subscriber counts, and manual sync trigger
      "},{"location":"docs/features/#email-templates","title":"Email Templates","text":"

      Create reusable email templates with variable substitution for campaign communications.

      "},{"location":"docs/features/#admin-routes_5","title":"Admin Routes","text":"
      • /app/email-templates \u2014 create and manage email templates with a visual editor
      "},{"location":"docs/getting-started/","title":"Getting Started","text":"

      This guide walks you through installing Changemaker Lite, running your first deployment, and logging into the admin dashboard.

      "},{"location":"docs/getting-started/#prerequisites","title":"Prerequisites","text":"
      • Docker 24+ and Docker Compose v2
      • OpenSSL (for secret generation)
      • A Linux server (Ubuntu 22.04+ recommended) or macOS for development
      • At least 2 GB RAM and 10 GB disk space
      • A domain name (optional, but recommended for production)
      "},{"location":"docs/getting-started/#installation","title":"Installation","text":""},{"location":"docs/getting-started/#1-clone-the-repository","title":"1. Clone the Repository","text":"
      git clone https://gitea.bnkops.com/admin/changemaker.lite\ncd changemaker.lite\ngit checkout v2\n
      "},{"location":"docs/getting-started/#2-run-the-configuration-wizard","title":"2. Run the Configuration Wizard","text":"

      The fastest way to get a working .env file is the interactive configuration wizard:

      bash config.sh\n

      The wizard walks you through each step:

      Step What it does Prerequisites check Verifies Docker, Docker Compose, and OpenSSL are installed Domain Sets your root domain and updates all subdomain references (nginx, Gitea, n8n, MkDocs, etc.) Admin credentials Prompts for the initial super-admin email and password (enforces 12+ chars, uppercase, lowercase, digit) Secret generation Auto-generates 16 unique secrets \u2014 JWT keys, encryption key, database passwords, Redis password, API tokens SMTP Optionally configures production SMTP (defaults to MailHog for development) Feature flags Enable/disable Media Manager and Listmonk newsletter sync Pangolin tunnel Optionally configures tunnel credentials for public access CORS Auto-sets allowed origins based on your domain Homepage Generates configs/homepage/services.yaml with all service links for your domain Permissions Creates required directories and sets container-friendly permissions

      After completion you'll have a fully populated .env with no placeholder passwords remaining.

      Already have a .env?

      If a .env file exists, the wizard offers to back it up before creating a fresh one, or update values in place.

      What the wizard looks like
        \u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2557  \u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2557   \u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\n \u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255d\u2588\u2588\u2551  \u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2557  \u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255d \u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255d\n \u2588\u2588\u2551     \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2554\u2588\u2588\u2557 \u2588\u2588\u2551\u2588\u2588\u2551  \u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2557\n \u2588\u2588\u2551     \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2551\u2588\u2588\u2551\u255a\u2588\u2588\u2557\u2588\u2588\u2551\u2588\u2588\u2551   \u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u255d\n \u255a\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2551  \u2588\u2588\u2551\u2588\u2588\u2551  \u2588\u2588\u2551\u2588\u2588\u2551 \u255a\u2588\u2588\u2588\u2588\u2551\u255a\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255d\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\n  \u255a\u2550\u2550\u2550\u2550\u2550\u255d\u255a\u2550\u255d  \u255a\u2550\u255d\u255a\u2550\u255d  \u255a\u2550\u255d\u255a\u2550\u255d  \u255a\u2550\u2550\u2550\u255d \u255a\u2550\u2550\u2550\u2550\u2550\u255d \u255a\u2550\u2550\u2550\u2550\u2550\u2550\u255d\n\n \u2588\u2588\u2588\u2557   \u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2557  \u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2557\n \u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2551 \u2588\u2588\u2554\u255d\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255d\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\n \u2588\u2588\u2554\u2588\u2588\u2588\u2588\u2554\u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2554\u255d \u2588\u2588\u2588\u2588\u2588\u2557  \u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255d\n \u2588\u2588\u2551\u255a\u2588\u2588\u2554\u255d\u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2588\u2588\u2557 \u2588\u2588\u2554\u2550\u2550\u255d  \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\n \u2588\u2588\u2551 \u255a\u2550\u255d \u2588\u2588\u2551\u2588\u2588\u2551  \u2588\u2588\u2551\u2588\u2588\u2551  \u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2551  \u2588\u2588\u2551\n \u255a\u2550\u255d     \u255a\u2550\u255d\u255a\u2550\u255d  \u255a\u2550\u255d\u255a\u2550\u255d  \u255a\u2550\u255d\u255a\u2550\u2550\u2550\u2550\u2550\u2550\u255d\u255a\u2550\u255d  \u255a\u2550\u255d\n                     V2 Configuration Wizard\n\n[INFO] This wizard will create your .env file, generate secure secrets,\n[INFO] and prepare your system to run the full Changemaker Lite stack.\n
      "},{"location":"docs/getting-started/#3-manual-setup-alternative","title":"3. Manual Setup (Alternative)","text":"

      If you prefer to configure things by hand:

      cp .env.example .env\n

      Then edit .env and at minimum set these values:

      V2_POSTGRES_PASSWORD=<strong password>\nREDIS_PASSWORD=<strong password>\nJWT_ACCESS_SECRET=<openssl rand -hex 32>\nJWT_REFRESH_SECRET=<openssl rand -hex 32>\nENCRYPTION_KEY=<openssl rand -hex 32>\nINITIAL_ADMIN_PASSWORD=<12+ chars, mixed case + digit>\n

      See Environment Variables for every available option.

      "},{"location":"docs/getting-started/#4-start-services","title":"4. Start Services","text":"
      # Start core services\ndocker compose up -d v2-postgres redis api admin\n\n# Run database migrations and seed the initial admin account\ndocker compose exec api npx prisma migrate deploy\ndocker compose exec api npx prisma db seed\n
      "},{"location":"docs/getting-started/#5-log-in","title":"5. Log In","text":"

      Open http://localhost:3000 and sign in with the admin email and password you configured.

      Change your password

      If you used the wizard's generated password, change it immediately from the admin dashboard.

      "},{"location":"docs/getting-started/#optional-services","title":"Optional Services","text":"

      Once the core is running, add more services as needed:

      # Reverse proxy (required for subdomain routing)\ndocker compose up -d nginx\n\n# Video library\ndocker compose up -d media-api\n\n# Newsletters\ndocker compose up -d listmonk-app\n\n# Service dashboard\ndocker compose up -d homepage\n\n# All services at once\ndocker compose up -d\n\n# Monitoring stack (Prometheus, Grafana, Alertmanager)\ndocker compose --profile monitoring up -d\n
      "},{"location":"docs/getting-started/#next-steps","title":"Next Steps","text":"
      • Environment Variables \u2014 complete .env reference with every configurable option
      • Feature Guides \u2014 explore campaigns, map, and media
      • Deployment \u2014 production setup with SSL and tunneling
      • Architecture \u2014 understand the system design
      "},{"location":"docs/getting-started/environment-variables/","title":"Environment Variables","text":"

      Changemaker Lite uses a single .env file at the project root to configure all services. Copy the example file to get started:

      cp .env.example .env\n

      Security Essentials

      • Change every REQUIRED_STRONG_PASSWORD_CHANGE_THIS value before starting services
      • Generate secrets with openssl rand -hex 32 (or -hex 16 where noted)
      • Never commit .env to version control
      • Use unique values for each secret \u2014 do not reuse JWT secrets as encryption keys
      "},{"location":"docs/getting-started/environment-variables/#quick-reference","title":"Quick Reference","text":"

      Variables are grouped by service. Each table marks whether a variable is required for a basic deployment or optional (has a sensible default or only needed for specific features).

      Symbol Meaning Must be set before first run Has a working default; change for production Feature flag \u2014 opt-in"},{"location":"docs/getting-started/environment-variables/#general","title":"General","text":"Variable Default Description NODE_ENV development Set to production for production deployments. Controls logging, error detail, and security checks. DOMAIN cmlite.org Root domain. Used for nginx subdomain routing (app.DOMAIN, api.DOMAIN, etc.). The root domain serves the MkDocs documentation site; all application routes live under app.DOMAIN. USER_ID 1000 UID for container file ownership. Match your host user's UID (id -u). GROUP_ID 1000 GID for container file ownership. Match your host user's GID (id -g). DOCKER_GROUP_ID 984 GID of the docker group on the host. Needed for containers that access the Docker socket. Find with getent group docker."},{"location":"docs/getting-started/environment-variables/#postgresql-main-database","title":"PostgreSQL (Main Database)","text":"

      The primary database for both the Express API and the Fastify Media API (shared).

      Variable Default Description V2_POSTGRES_USER changemaker Database username. V2_POSTGRES_PASSWORD \u2014 Must change. Database password. V2_POSTGRES_DB changemaker_v2 Database name. V2_POSTGRES_PORT 5433 Host port mapping. The container listens on 5432 internally.

      Connection string

      The DATABASE_URL is constructed automatically inside Docker. If running locally, set:

      DATABASE_URL=postgresql://changemaker:YOUR_PASSWORD@localhost:5433/changemaker_v2\n

      "},{"location":"docs/getting-started/environment-variables/#jwt-authentication","title":"JWT Authentication","text":"Variable Default Description JWT_ACCESS_SECRET \u2014 Secret for signing access tokens. Generate with openssl rand -hex 32. JWT_REFRESH_SECRET \u2014 Secret for signing refresh tokens. Must differ from the access secret. JWT_ACCESS_EXPIRY 15m Access token lifetime. Short-lived by design. JWT_REFRESH_EXPIRY 7d Refresh token lifetime. Tokens are rotated atomically on each refresh."},{"location":"docs/getting-started/environment-variables/#encryption-key","title":"Encryption Key","text":"Variable Default Description ENCRYPTION_KEY \u2014 AES key for encrypting secrets stored in the database (SMTP passwords, API keys, etc.). Generate with openssl rand -hex 32. Must not reuse a JWT secret. Required in production (NODE_ENV=production)."},{"location":"docs/getting-started/environment-variables/#initial-admin-account","title":"Initial Admin Account","text":"

      These credentials create the first super-admin user during database seeding (npx prisma db seed).

      Variable Default Description INITIAL_ADMIN_EMAIL admin@cmlite.org Email address for the initial admin. INITIAL_ADMIN_PASSWORD \u2014 Must change. Must be 12+ characters with uppercase, lowercase, and a digit. Change this password after first login."},{"location":"docs/getting-started/environment-variables/#api-server","title":"API Server","text":"Variable Default Description API_PORT 4000 Host port for the Express API. API_URL http://localhost:4000 Public URL of the API. Used for generating links in emails and QR codes. CORS_ORIGINS http://localhost:3000,http://localhost Comma-separated list of allowed CORS origins. Add your production domain (e.g., https://app.yourdomain.org) for production.

      Production CORS

      If you deploy behind a tunnel (Pangolin, Cloudflare) and API requests fail with CORS errors, add your production app. subdomain here:

      CORS_ORIGINS=https://app.betteredmonton.org,http://localhost:3000,http://localhost\n
      Then restart the API: docker compose restart api

      "},{"location":"docs/getting-started/environment-variables/#admin-gui","title":"Admin GUI","text":"Variable Default Description ADMIN_PORT 3000 Host port for the React admin dashboard. ADMIN_URL http://localhost:3000 Public URL of the admin GUI."},{"location":"docs/getting-started/environment-variables/#nginx-reverse-proxy","title":"Nginx Reverse Proxy","text":"Variable Default Description NGINX_HTTP_PORT 80 HTTP port. All subdomains route through nginx. NGINX_HTTPS_PORT 443 HTTPS port. SSL is typically handled by the tunnel provider (Pangolin/Cloudflare)."},{"location":"docs/getting-started/environment-variables/#redis","title":"Redis","text":"

      Shared by rate limiting, BullMQ job queues, geocoding cache, and session data.

      Variable Default Description REDIS_PASSWORD \u2014 Must change. Redis requires authentication. REDIS_URL redis://:${REDIS_PASSWORD}@redis-changemaker:6379 Full connection URL. Uses the password variable automatically."},{"location":"docs/getting-started/environment-variables/#email-smtp","title":"Email / SMTP","text":"Variable Default Description SMTP_HOST mailhog-changemaker SMTP server. Default points to the MailHog dev container. SMTP_PORT 1025 SMTP port. 1025 for MailHog, 587 for most production SMTP. SMTP_USER (empty) SMTP username. Not needed for MailHog. SMTP_PASS (empty) SMTP password. SMTP_FROM noreply@cmlite.org \"From\" address on outgoing emails. SMTP_FROM_NAME Changemaker Lite Display name for the \"From\" header. EMAIL_TEST_MODE true When true, all emails go to MailHog instead of real SMTP. Set to false in production. TEST_EMAIL_RECIPIENT admin@cmlite.org Catch-all recipient when test mode is on.

      Development email

      With EMAIL_TEST_MODE=true, all outgoing email is captured in MailHog at http://localhost:8025. No real emails are sent.

      "},{"location":"docs/getting-started/environment-variables/#listmonk-newsletters","title":"Listmonk (Newsletters)","text":"

      Listmonk handles newsletter/marketing campaigns. Sync with the main platform is opt-in.

      Variable Default Description LISTMONK_PORT 9001 Listmonk web UI port. LISTMONK_DB_PORT 5432 Listmonk's own PostgreSQL port (separate from the main DB). LISTMONK_DB_USER listmonk Listmonk database user. LISTMONK_DB_PASSWORD \u2014 Listmonk database password. LISTMONK_DB_NAME listmonk Listmonk database name. LISTMONK_WEB_ADMIN_USER admin Login for the Listmonk web dashboard. LISTMONK_WEB_ADMIN_PASSWORD \u2014 Password for the Listmonk web dashboard. LISTMONK_API_USER v2-api API user for programmatic access (auto-created by init container). LISTMONK_API_TOKEN \u2014 Token for API user. Generate with openssl rand -hex 16. LISTMONK_ADMIN_USER v2-api Same as LISTMONK_API_USER (used by the sync service). LISTMONK_ADMIN_PASSWORD \u2014 Same as LISTMONK_API_TOKEN. LISTMONK_SYNC_ENABLED false Set to true to sync participants/locations/users to Listmonk lists. LISTMONK_PROXY_PORT 9002 Nginx proxy port for Listmonk. Listmonk SMTP settings

      Listmonk has its own SMTP configuration, separate from the main platform's:

      Variable Default Description LISTMONK_SMTP_HOST mailhog-changemaker SMTP host for Listmonk. LISTMONK_SMTP_PORT 1025 SMTP port. LISTMONK_SMTP_USER (empty) SMTP username. LISTMONK_SMTP_PASSWORD (empty) SMTP password. LISTMONK_SMTP_TLS_TYPE none TLS mode: none, STARTTLS, or TLS. LISTMONK_SMTP_FROM Changemaker Lite <noreply@cmlite.org> From address for newsletters."},{"location":"docs/getting-started/environment-variables/#represent-api-canadian-electoral-data","title":"Represent API (Canadian Electoral Data)","text":"Variable Default Description REPRESENT_API_URL https://represent.opennorth.ca OpenNorth Represent API endpoint. Used for postal code \u2192 representative lookups. No API key required."},{"location":"docs/getting-started/environment-variables/#nocodb-data-browser","title":"NocoDB (Data Browser)","text":"

      Read-only database browser. Useful for inspecting data without SQL.

      Variable Default Description NOCODB_V2_PORT / NOCODB_PORT 8091 Host port for the NocoDB web UI. NOCODB_URL http://changemaker-v2-nocodb:8080 Internal Docker URL. NC_ADMIN_EMAIL admin@cmlite.org NocoDB admin email. NC_ADMIN_PASSWORD \u2014 NocoDB admin password."},{"location":"docs/getting-started/environment-variables/#media-manager","title":"Media Manager","text":"

      Video library with upload, analytics, scheduling, and a public gallery.

      Variable Default Description ENABLE_MEDIA_FEATURES false Set to true to enable the media system. MEDIA_API_PORT 4100 Fastify media API port. MEDIA_API_PUBLIC_URL http://media-api:4100 Internal URL for the media API container. MEDIA_ROOT /media/library Path to the video library inside the container. MEDIA_UPLOADS /media/uploads Path for upload processing. MAX_UPLOAD_SIZE_GB 10 Maximum single-file upload size in gigabytes. VIDEO_PLAYER_DEBUG false Enable verbose video player logging. Analytics & scheduling settings Variable Default Description VIDEO_ANALYTICS_RETENTION_DAYS 90 Days to retain analytics data. GDPR-compliant with IP hashing. VIDEO_ANALYTICS_IP_HASHING_ENABLED true Hash viewer IPs for privacy. VIDEO_SCHEDULE_DEFAULT_TIMEZONE UTC Default timezone for scheduled publishing. VIDEO_SCHEDULE_NOTIFICATION_ENABLED true Notify on scheduled publish/unpublish. VIDEO_PREVIEW_LINK_EXPIRY_HOURS 24 Preview link JWT expiry (hours)."},{"location":"docs/getting-started/environment-variables/#gitea-git-hosting","title":"Gitea (Git Hosting)","text":"

      Self-hosted Git repository. Optional service.

      Variable Default Description GITEA_PORT / GITEA_WEB_PORT 3030 Gitea web UI port. GITEA_SSH_PORT 2222 Gitea SSH port for git operations. GITEA_DB_TYPE mysql Database type (Gitea uses its own MySQL). GITEA_DB_HOST gitea-db:3306 Internal database host. GITEA_DB_NAME gitea Database name. GITEA_DB_USER gitea Database user. GITEA_DB_PASSWD \u2014 Gitea database password. GITEA_DB_ROOT_PASSWORD \u2014 MySQL root password for Gitea. GITEA_ROOT_URL https://git.cmlite.org Public-facing URL for Gitea. GITEA_DOMAIN git.cmlite.org Domain used in git clone URLs."},{"location":"docs/getting-started/environment-variables/#n8n-workflow-automation","title":"n8n (Workflow Automation)","text":"Variable Default Description N8N_PORT 5678 n8n web UI port. N8N_HOST n8n.cmlite.org Public hostname for n8n. N8N_ENCRYPTION_KEY \u2014 Encryption key for n8n credentials storage. N8N_USER_EMAIL admin@example.com Initial n8n admin email. N8N_USER_PASSWORD \u2014 Initial n8n admin password. GENERIC_TIMEZONE UTC Timezone for n8n cron triggers."},{"location":"docs/getting-started/environment-variables/#mkdocs-documentation","title":"MkDocs (Documentation)","text":"Variable Default Description MKDOCS_PORT 4003 MkDocs dev server port (live preview). MKDOCS_SITE_SERVER_PORT 4001 MkDocs static site server port. BASE_DOMAIN https://cmlite.org Base URL for generated documentation links. MKDOCS_PREVIEW_URL http://mkdocs:8000 Internal container URL. MKDOCS_DOCS_PATH /mkdocs/docs Documentation source directory inside the container."},{"location":"docs/getting-started/environment-variables/#code-server-web-ide","title":"Code Server (Web IDE)","text":"Variable Default Description CODE_SERVER_PORT 8888 Code Server web UI port. CODE_SERVER_URL http://code-server:8080 Internal container URL. USER_NAME coder User account inside the Code Server container."},{"location":"docs/getting-started/environment-variables/#homepage-service-dashboard","title":"Homepage (Service Dashboard)","text":"Variable Default Description HOMEPAGE_PORT 3010 Homepage web UI port. HOMEPAGE_EMBED_PORT 8887 Port for iframe embedding in admin. HOMEPAGE_VAR_BASE_URL http://localhost Base URL used in Homepage service links."},{"location":"docs/getting-started/environment-variables/#mini-qr-qr-code-generator","title":"Mini QR (QR Code Generator)","text":"Variable Default Description MINI_QR_PORT 8089 Mini QR direct access port. MINI_QR_URL http://mini-qr:8080 Internal container URL. MINI_QR_EMBED_PORT 8885 Port for iframe embedding (walk sheets, cut exports)."},{"location":"docs/getting-started/environment-variables/#excalidraw-whiteboard","title":"Excalidraw (Whiteboard)","text":"Variable Default Description EXCALIDRAW_PORT 8090 Excalidraw web UI port. EXCALIDRAW_URL http://excalidraw-changemaker:80 Internal container URL. EXCALIDRAW_EMBED_PORT 8886 Port for iframe embedding. EXCALIDRAW_WS_URL wss://draw.cmlite.org WebSocket URL for real-time collaboration."},{"location":"docs/getting-started/environment-variables/#mailhog-development-email","title":"MailHog (Development Email)","text":"Variable Default Description MAILHOG_SMTP_PORT 1025 SMTP port for capturing emails. MAILHOG_WEB_PORT 8025 Web UI to view captured emails."},{"location":"docs/getting-started/environment-variables/#nar-national-address-register","title":"NAR (National Address Register)","text":"

      Canadian address data import for geographic canvassing.

      Variable Default Description NAR_DATA_DIR /data Path to extracted NAR data inside the container. Expects YYYYMM/Addresses/ and YYYYMM/Locations/ subdirectories. Mount via ./data:/data:ro in Docker Compose.

      Download NAR data from Statistics Canada.

      "},{"location":"docs/getting-started/environment-variables/#geocoding","title":"Geocoding","text":"

      Multi-provider geocoding for address resolution. Works out of the box with free providers; optional paid providers improve accuracy.

      Variable Default Description MAPBOX_API_KEY (empty) Mapbox API key for improved geocoding accuracy. Free tier: 100k requests/month. Sign up. GEOCODING_RATE_LIMIT_MS 1100 Delay between requests to free providers (ms). Respects rate limits. GEOCODING_CACHE_ENABLED true Enable Redis-backed geocoding cache. GEOCODING_CACHE_TTL_HOURS 24 Cache lifetime in hours. GOOGLE_MAPS_API_KEY (empty) Google Maps API key. Most accurate but $0.005/request after free tier. GOOGLE_MAPS_ENABLED false Enable Google Maps as a geocoding provider. GEOCODING_PARALLEL_ENABLED true Enable parallel geocoding for bulk imports (~10x speedup). GEOCODING_BATCH_SIZE 10 Number of concurrent geocoding requests during bulk operations. BULK_GEOCODE_ENABLED true Enable bulk re-geocoding from the admin UI. BULK_GEOCODE_MAX_BATCH 5000 Maximum locations per bulk geocoding run."},{"location":"docs/getting-started/environment-variables/#overpass-area-import","title":"Overpass / Area Import","text":"

      OpenStreetMap data import for map enrichment.

      Variable Default Description OVERPASS_API_URL https://overpass-api.de/api/interpreter Overpass API endpoint. Use a private instance for heavy usage. OVERPASS_MIN_DELAY_MS 30000 Minimum delay between requests (ms). The public API requires 30 seconds. AREA_IMPORT_MAX_GRID_POINTS 500 Maximum reverse-geocode grid points per area import."},{"location":"docs/getting-started/environment-variables/#pangolin-tunnel","title":"Pangolin Tunnel","text":"

      Expose services to the internet without port forwarding, using a self-hosted Pangolin instance.

      Variable Default Description PANGOLIN_API_URL https://api.bnkserve.org/v1 Pangolin server API endpoint. PANGOLIN_API_KEY (empty) API key for Pangolin management. PANGOLIN_ORG_ID (empty) Organization ID in Pangolin. PANGOLIN_SITE_ID (empty) Site ID (populated after setup via admin GUI). PANGOLIN_ENDPOINT https://pangolin.bnkserve.org Pangolin tunnel endpoint. PANGOLIN_NEWT_ID (empty) Newt client ID (populated after setup). PANGOLIN_NEWT_SECRET (empty) Newt client secret (populated after setup).

      Setup flow

      Configure the tunnel from Admin \u2192 Settings \u2192 Pangolin. The setup wizard walks you through creating a site, copying credentials, and connecting the Newt container. See Deployment for the full guide.

      "},{"location":"docs/getting-started/environment-variables/#monitoring","title":"Monitoring","text":"

      These services are behind the monitoring Docker Compose profile. Start them with:

      docker compose --profile monitoring up -d\n
      Variable Default Description PROMETHEUS_PORT 9090 Prometheus web UI / query port. GRAFANA_PORT 3001 Grafana dashboard port. GRAFANA_ADMIN_PASSWORD admin Change in production. GRAFANA_ROOT_URL http://localhost:3001 Public URL for Grafana (used in links). CADVISOR_PORT 8080 cAdvisor container metrics port. NODE_EXPORTER_PORT 9100 Prometheus node exporter port. REDIS_EXPORTER_PORT 9121 Redis metrics exporter port. ALERTMANAGER_PORT 9093 Alertmanager web UI port. GOTIFY_PORT 8889 Gotify push notification port. GOTIFY_ADMIN_USER admin Gotify admin username. GOTIFY_ADMIN_PASSWORD admin Change in production."},{"location":"docs/getting-started/environment-variables/#generating-secrets","title":"Generating Secrets","text":"

      Use these commands to generate all required secrets at once:

      # JWT secrets (two separate values)\necho \"JWT_ACCESS_SECRET=$(openssl rand -hex 32)\"\necho \"JWT_REFRESH_SECRET=$(openssl rand -hex 32)\"\n\n# Encryption key (must differ from JWT secrets)\necho \"ENCRYPTION_KEY=$(openssl rand -hex 32)\"\n\n# Database and Redis passwords\necho \"V2_POSTGRES_PASSWORD=$(openssl rand -hex 24)\"\necho \"REDIS_PASSWORD=$(openssl rand -hex 24)\"\n\n# Listmonk\necho \"LISTMONK_DB_PASSWORD=$(openssl rand -hex 24)\"\necho \"LISTMONK_WEB_ADMIN_PASSWORD=$(openssl rand -hex 16)\"\nLISTMONK_TOKEN=$(openssl rand -hex 16)\necho \"LISTMONK_API_TOKEN=$LISTMONK_TOKEN\"\necho \"LISTMONK_ADMIN_PASSWORD=$LISTMONK_TOKEN\"\n\n# Supporting services\necho \"GITEA_DB_PASSWD=$(openssl rand -hex 24)\"\necho \"GITEA_DB_ROOT_PASSWORD=$(openssl rand -hex 24)\"\necho \"N8N_ENCRYPTION_KEY=$(openssl rand -hex 32)\"\necho \"N8N_USER_PASSWORD=$(openssl rand -hex 16)\"\necho \"NC_ADMIN_PASSWORD=$(openssl rand -hex 16)\"\necho \"INITIAL_ADMIN_PASSWORD=$(openssl rand -base64 18)\"\n

      Tip

      Copy the output and paste the values into your .env file. The INITIAL_ADMIN_PASSWORD uses base64 encoding to ensure it contains uppercase, lowercase, and digits (meeting the password policy).

      "},{"location":"docs/getting-started/environment-variables/#minimal-vs-full-deployment","title":"Minimal vs Full Deployment","text":"Minimal (Core Only)Full Stack

      For a basic deployment with campaigns, map, and admin:

      Required variables
      V2_POSTGRES_PASSWORD=...\nREDIS_PASSWORD=...\nJWT_ACCESS_SECRET=...\nJWT_REFRESH_SECRET=...\nENCRYPTION_KEY=...\nINITIAL_ADMIN_PASSWORD=...\n
      Start services
      docker compose up -d v2-postgres redis api admin\n

      For the complete platform including media, newsletters, monitoring, and all services:

      Additional variables needed
      # Everything above, plus:\nENABLE_MEDIA_FEATURES=true\nLISTMONK_SYNC_ENABLED=true\nLISTMONK_DB_PASSWORD=...\nLISTMONK_WEB_ADMIN_PASSWORD=...\nLISTMONK_API_TOKEN=...\nNC_ADMIN_PASSWORD=...\nGITEA_DB_PASSWD=...\nGITEA_DB_ROOT_PASSWORD=...\nN8N_ENCRYPTION_KEY=...\nN8N_USER_PASSWORD=...\nEMAIL_TEST_MODE=false\nSMTP_HOST=smtp.your-provider.com\nSMTP_PORT=587\nSMTP_USER=you@example.com\nSMTP_PASS=your-smtp-password\n
      Start services
      docker compose up -d\ndocker compose --profile monitoring up -d\n
      "},{"location":"docs/services/","title":"Services","text":"

      Changemaker Lite orchestrates 20+ services via Docker Compose. This page is your map to every service: what it does, how to reach it, and where to find its upstream documentation.

      "},{"location":"docs/services/#core-platform","title":"Core Platform","text":"

      The essential services that power the application.

      • Express API

        Main V2 API server. Handles authentication, campaigns, map, shifts, pages, email, and all business logic. Prisma ORM with PostgreSQL.

        Port: 4000 \u00b7 Container: changemaker-v2-api

        API Reference

      • Fastify Media API

        Video library server. Upload, metadata extraction (FFprobe), analytics, scheduled publishing, and public gallery. Shares the same PostgreSQL database.

        Port: 4100 \u00b7 Container: changemaker-media-api

        Media Guide

      • Admin GUI

        React single-page application (Vite + Ant Design + Zustand). Serves the admin dashboard, public campaign pages, volunteer portal, and media gallery \u2014 all from one build.

        Port: 3000 \u00b7 Container: changemaker-v2-admin

        Feature Guides

      • PostgreSQL 16

        Primary database shared by both APIs. Managed by Prisma migrations. Contains 30+ tables covering users, campaigns, locations, shifts, media, and more.

        Port: 5433 (host) / 5432 (container) \u00b7 Container: changemaker-v2-postgres

        PostgreSQL Docs

      • Redis

        In-memory store for rate limiting, BullMQ job queues (email, video scheduling), geocoding cache, and session data. Requires authentication.

        Port: 6379 \u00b7 Container: redis-changemaker

        Redis Docs

      • Nginx

        Reverse proxy handling all subdomain routing (app., api., media., docs., etc.). Includes security headers (HSTS, CSP, Permissions-Policy) and WebSocket support.

        Port: 80 / 443 \u00b7 Container: changemaker-v2-nginx

        Nginx Docs

      "},{"location":"docs/services/#communication-email","title":"Communication & Email","text":"
      • Listmonk

        Self-hosted newsletter and mailing list manager. Drop-in replacement for Mailchimp. Opt-in sync with the main platform imports participants, locations, and users as subscriber lists.

        Port: 9001 \u00b7 Container: listmonk-app \u00b7 Subdomain: listmonk.DOMAIN

        Listmonk Docs

      • MailHog

        Email capture for development. All outgoing email is intercepted and displayed in a web UI when EMAIL_TEST_MODE=true. No real emails are sent.

        Port: 8025 (web) / 1025 (SMTP) \u00b7 Container: mailhog-changemaker \u00b7 Subdomain: mail.DOMAIN

        MailHog GitHub

      "},{"location":"docs/services/#content-editing","title":"Content & Editing","text":"
      • MkDocs

        Material-themed documentation site with full-text search, blog, social cards, and Jinja2 template overrides. Two containers: live preview (dev) and static site (production).

        Port: 4003 (dev) / 4001 (static) \u00b7 Container: mkdocs-changemaker \u00b7 Subdomain: docs.DOMAIN

        MkDocs Material

      • Code Server

        Full VS Code in the browser. Edit configuration files, templates, and documentation from anywhere without SSH. Supports extensions.

        Port: 8888 \u00b7 Container: code-server-changemaker \u00b7 Subdomain: code.DOMAIN

        Code Server Docs

      "},{"location":"docs/services/#data-automation","title":"Data & Automation","text":"
      • NocoDB

        Airtable-alternative database browser. Provides a spreadsheet-like interface to browse, filter, sort, and export campaign data. Read-only access to the main database.

        Port: 8091 \u00b7 Container: changemaker-v2-nocodb \u00b7 Subdomain: db.DOMAIN

        NocoDB Docs

      • n8n

        Visual workflow automation platform. Connect APIs, trigger actions on events, schedule tasks, and build custom integrations \u2014 all without code. 400+ built-in integrations.

        Port: 5678 \u00b7 Container: n8n-changemaker \u00b7 Subdomain: n8n.DOMAIN

        n8n Docs

      • Gitea

        Self-hosted Git repository hosting. Version control for campaign code, configuration, templates, and documentation. Includes issues, pull requests, and CI/CD.

        Port: 3030 (web) / 2222 (SSH) \u00b7 Container: gitea-changemaker \u00b7 Subdomain: git.DOMAIN

        Gitea Docs

      "},{"location":"docs/services/#utilities","title":"Utilities","text":"
      • Mini QR

        Lightweight QR code generator. Produces PNG images for walk sheets, campaign materials, and event signage. Embedded in the admin dashboard via iframe.

        Port: 8089 \u00b7 Container: mini-qr \u00b7 Subdomain: qr.DOMAIN

      • Homepage

        Service dashboard showing the status of all containers at a glance. Auto-generated services.yaml from config.sh provides both production and local links.

        Port: 3010 \u00b7 Container: homepage-changemaker \u00b7 Subdomain: home.DOMAIN

        Homepage Docs

      • Excalidraw

        Collaborative whiteboard for brainstorming, diagramming, and visual planning. Real-time collaboration via WebSocket.

        Port: 8090 \u00b7 Container: excalidraw-changemaker \u00b7 Subdomain: draw.DOMAIN

        Excalidraw

      "},{"location":"docs/services/#networking-tunneling","title":"Networking & Tunneling","text":"
      • Pangolin + Newt

        Self-hosted tunnel server with the Newt client container. Exposes your services to the internet without port forwarding. Handles SSL/TLS, works behind CGNAT and double NAT.

        Container: newt-changemaker \u00b7 Managed from Admin \u2192 Settings \u2192 Tunnel

        Deployment Guide \u00b7 Pangolin GitHub

      "},{"location":"docs/services/#monitoring-stack","title":"Monitoring Stack","text":"

      These services run behind the monitoring Docker Compose profile. Start them with:

      docker compose --profile monitoring up -d\n
      • Prometheus

        Metrics collection and time-series database. Scrapes 12 custom cm_* application metrics plus container, host, and Redis metrics. Pre-configured alert rules.

        Port: 9090 \u00b7 Container: prometheus-changemaker

        Prometheus Docs

      • Grafana

        Metrics visualization with 3 auto-provisioned dashboards: API Overview, Infrastructure, and Campaign Activity. Supports custom dashboards and alerting.

        Port: 3001 \u00b7 Container: grafana-changemaker \u00b7 Subdomain: grafana.DOMAIN

        Grafana Docs

      • Alertmanager

        Alert routing and notification delivery. Receives alerts from Prometheus and dispatches to Gotify, email, or webhooks based on configurable rules.

        Port: 9093 \u00b7 Container: alertmanager-changemaker

        Alertmanager Docs

      • cAdvisor

        Container resource metrics. Exposes CPU, memory, network, and filesystem usage per container for Prometheus to scrape.

        Port: 8080 \u00b7 Container: cadvisor-changemaker

        cAdvisor GitHub

      • Node Exporter

        Host system metrics. Reports CPU, memory, disk, and network stats for the underlying server.

        Port: 9100 \u00b7 Container: node-exporter-changemaker

        Node Exporter

      • Redis Exporter

        Redis metrics for Prometheus. Exposes connection counts, memory usage, command stats, and keyspace info.

        Port: 9121 \u00b7 Container: redis-exporter-changemaker

        Redis Exporter GitHub

      • Gotify

        Self-hosted push notification server. Receives alerts from Alertmanager and delivers them to mobile/desktop clients.

        Port: 8889 \u00b7 Container: gotify-changemaker

        Gotify Docs

      "},{"location":"docs/services/#quick-reference","title":"Quick Reference","text":"

      All services at a glance with their default ports and subdomains.

      Service Port Subdomain Docker Profile Express API 4000 api. default Media API 4100 media. default Admin GUI 3000 app. default PostgreSQL 5433 \u2014 default Redis 6379 \u2014 default Nginx 80/443 (all) default Listmonk 9001 listmonk. default MailHog 8025 mail. default MkDocs (dev) 4003 docs. default MkDocs (static) 4001 (root) default Code Server 8888 code. default NocoDB 8091 db. default n8n 5678 n8n. default Gitea 3030 git. default Mini QR 8089 qr. default Homepage 3010 home. default Excalidraw 8090 draw. default Newt (tunnel) \u2014 \u2014 default Prometheus 9090 \u2014 monitoring Grafana 3001 grafana. monitoring Alertmanager 9093 \u2014 monitoring cAdvisor 8080 \u2014 monitoring Node Exporter 9100 \u2014 monitoring Redis Exporter 9121 \u2014 monitoring Gotify 8889 \u2014 monitoring

      Starting services selectively

      You don't need to run everything. Start only what you need:

      # Core only\ndocker compose up -d v2-postgres redis api admin\n\n# Add nginx for subdomain routing\ndocker compose up -d nginx\n\n# Add monitoring\ndocker compose --profile monitoring up -d\n

      See Getting Started for the recommended startup order.

      "},{"location":"docs/troubleshooting/","title":"Troubleshooting","text":"

      Common issues and their solutions when running Changemaker Lite.

      Under Construction

      This troubleshooting guide is being expanded. Check back soon for more solutions.

      "},{"location":"docs/troubleshooting/#cors-errors-in-production","title":"CORS Errors in Production","text":"

      Symptom: Browser console shows CORS errors when accessing production domain.

      Fix: Add your production domain to CORS_ORIGINS in .env:

      CORS_ORIGINS=https://app.yourdomain.org,http://localhost:3000\n

      Then restart the API: docker compose restart api

      "},{"location":"docs/troubleshooting/#pangolin-tunnel-403302-errors","title":"Pangolin Tunnel 403/302 Errors","text":"

      Symptom: All API endpoints return 302 redirects to Pangolin auth page.

      Fix: In the Pangolin dashboard, set each resource to \"Not Protected\" (public access).

      "},{"location":"docs/troubleshooting/#database-connection-failures","title":"Database Connection Failures","text":"
      1. Check PostgreSQL: docker compose ps v2-postgres
      2. Verify DATABASE_URL in .env
      3. View logs: docker compose logs v2-postgres --tail 50
      "},{"location":"docs/troubleshooting/#redis-connection-failures","title":"Redis Connection Failures","text":"
      1. Check Redis: docker compose ps redis-changemaker
      2. Verify REDIS_PASSWORD and REDIS_URL format in .env
      3. Test: docker compose exec redis-changemaker redis-cli -a $REDIS_PASSWORD ping
      "},{"location":"docs/troubleshooting/#api-not-starting","title":"API Not Starting","text":"
      1. Check logs: docker compose logs api --tail 100
      2. Verify all required env vars are set (see .env.example)
      3. Run migrations: docker compose exec api npx prisma migrate deploy
      "},{"location":"docs/volunteer/","title":"Volunteer Guide","text":"

      Welcome! This guide walks you through everything you need to know as a campaign volunteer \u2014 from signing up for your first shift to canvassing door-to-door with the GPS map.

      "},{"location":"docs/volunteer/#getting-started","title":"Getting Started","text":""},{"location":"docs/volunteer/#1-sign-up-for-a-shift","title":"1. Sign Up for a Shift","text":"

      Visit the Shifts page (your organizer will share the link, or find it at /shifts). Browse available time slots, pick one that works for you, and fill in your name and email. You'll receive a confirmation email with your login credentials.

      "},{"location":"docs/volunteer/#2-log-in","title":"2. Log In","text":"

      Go to the login page and sign in with the email and password from your confirmation. If you already have an account, just sign in normally.

      "},{"location":"docs/volunteer/#3-open-the-volunteer-portal","title":"3. Open the Volunteer Portal","text":"

      After logging in, you'll land on the volunteer map \u2014 a full-screen view of your assigned canvassing area. This is your home base.

      "},{"location":"docs/volunteer/#the-volunteer-map","title":"The Volunteer Map","text":"

      The volunteer map is your main tool for canvassing. It shows all the addresses in your assigned area and tracks your position with GPS.

      "},{"location":"docs/volunteer/#what-you-see","title":"What You See","text":"
      • Colored markers \u2014 each marker is an address. Colors indicate the outcome of the last visit (green = supportive, red = opposed, grey = not yet visited, etc.)
      • Clusters \u2014 when zoomed out, markers group together and show the number of addresses in that area. Tap a cluster to zoom in.
      • Blue dot \u2014 your current GPS position
      • Walking route \u2014 a suggested path through the addresses (dotted line)
      "},{"location":"docs/volunteer/#recording-a-visit","title":"Recording a Visit","text":"
      1. Tap a marker to select an address
      2. A bottom panel slides up showing the address details
      3. Tap Record Visit to log what happened:
        • Not Home \u2014 nobody answered
        • Supportive \u2014 positive interaction
        • Opposed \u2014 not supportive
        • Undecided \u2014 hasn't made up their mind
        • Moved \u2014 no longer lives there
        • Refused \u2014 declined to talk
      4. Optionally add a note about the visit
      5. Tap Save \u2014 the marker color updates immediately
      "},{"location":"docs/volunteer/#tips-for-canvassing","title":"Tips for Canvassing","text":"
      • Start a session before you begin knocking on doors \u2014 this tracks your route and time
      • End your session when you're done for the day
      • The map works offline for basic viewing, but you need a connection to save visits
      • If GPS is inaccurate, you can manually tap the correct marker on the map
      "},{"location":"docs/volunteer/#your-shifts","title":"Your Shifts","text":"

      Visit Shifts in the bottom navigation to see your upcoming and past shifts. Each shift shows:

      • Date and time
      • Assigned area (if linked)
      • A button to open the canvass map for that area
      "},{"location":"docs/volunteer/#activity-log","title":"Activity Log","text":"

      The Activity tab shows your complete visit history:

      • Outcome breakdown \u2014 pie chart of your visit outcomes
      • Visit list \u2014 each visit with address, outcome, time, and any notes
      • Stats \u2014 total visits, addresses covered, and sessions completed
      "},{"location":"docs/volunteer/#routes","title":"Routes","text":"

      The Routes tab shows your past canvassing routes on a map. This helps you see which areas you've covered and plan your next outing.

      "},{"location":"docs/volunteer/#browsing-public-pages","title":"Browsing Public Pages","text":"

      Tap your name/avatar in the header and select Browse Site to visit the public pages \u2014 campaigns, the public map, and shift signups. This is useful for sharing links with friends or checking campaign progress.

      "},{"location":"docs/volunteer/#faq","title":"FAQ","text":"

      Q: I can't find my assigned area on the map. A: Make sure your shift has an area assigned. Check with your organizer if nothing appears.

      Q: My GPS isn't working. A: Make sure you've allowed location access in your browser. Try moving to a window or stepping outside for better signal.

      Q: I accidentally recorded the wrong outcome. A: Visit the same address again and record the correct outcome. The most recent visit is what counts.

      Q: How do I sign up for more shifts? A: Visit the public shifts page (ask your organizer for the link, or go to /shifts).

      "}]} \ No newline at end of file +{"config":{"lang":["en"],"separator":"[\\s\\u200b\\-_,:!=\\[\\]()\"`/]+|\\.(?!\\d)|&[lg]t;|(?!\\b)(?=[A-Z][a-z])","pipeline":["stopWordFilter"],"fields":{"title":{"boost":1000.0},"text":{"boost":1.0},"tags":{"boost":1000000.0}}},"docs":[{"location":"test/","title":"Test","text":"

      Testing page.

      Loading... PNG 1920\u00d71040 vlcsnap-2026-01-09-15h39m52s898.png 0 views View \u2192 \ud83d\uded2 DIGITAL Test Product 1

      A test product

      $90.00

      Buy Now \u2014 $90.00

      Secure payment via Stripe. Browse all products

      View in Shop

      \u2764\ufe0f

      Support Our Work

      Every contribution makes a difference. Choose an amount below.

      $10 $25 $50 $100 Custom Make my donation anonymous Donate

      Secure payment via Stripe. Open full donate page

      Donate Now

      \u2764\ufe0f

      Support Our Work

      Every contribution makes a difference. Choose an amount below.

      $10 $25 $50 $100 Custom Amount

      \ud83d\uded2

      Browse Our Products

      Reports, toolkits, event tickets, and more.

      Shop Now Choose Your Plan

      Get access to exclusive content and features.

      View Plans

      \u2764\ufe0f

      Support Our Cause

      Your contribution helps us create lasting change in our community.

      Donate Now 9:54 Testing This Sucker 0 views Watch \u2192"},{"location":"blog/","title":"Blog","text":""},{"location":"comments/callback/","title":"Signing in...","text":"

      Completing sign in...

      You will be redirected back to the page you were on.

      "},{"location":"docs/","title":"Documentation","text":"

      Welcome to the Changemaker Lite documentation. Whether you're a campaign volunteer, an admin managing operations, or a sysadmin deploying the platform \u2014 start here.

      "},{"location":"docs/#use-the-platform","title":"Use the Platform","text":"
      • Getting Started

        Install Changemaker Lite, create your first admin account, and explore the dashboard.

        Getting Started

      • Feature Guides

        Campaigns, email advocacy, response walls, map locations, landing pages, and media.

        Feature Guides

      • Administration

        User management, roles and permissions, site settings, email templates, and newsletters.

        Administration

      • Volunteer Guide

        Sign up for shifts, use the canvassing map, record visits, and track your activity.

        Volunteer Guide

      "},{"location":"docs/#deploy-operate","title":"Deploy & Operate","text":"
      • Deployment

        Docker Compose setup, environment variables, SSL/TLS, backups, and production checklist.

        Deployment

      • Architecture

        Dual API design, database schema, authentication flow, and system diagram.

        Architecture

      • Services

        Nginx routing, Redis, PostgreSQL, Listmonk, MkDocs, Gitea, NocoDB, and more.

        Services

      • Monitoring

        Prometheus metrics, Grafana dashboards, Alertmanager rules, and health checks.

        Monitoring Coming soon

      "},{"location":"docs/#reference","title":"Reference","text":"
      • API Reference

        REST endpoints for auth, campaigns, locations, shifts, media, and more.

        API Reference

      • Troubleshooting

        Common errors, CORS issues, database problems, tunnel debugging, and FAQ.

        Troubleshooting

      • Security

        Password policy, rate limiting, token rotation, encryption, and audit report.

        Security See Deployment

      • Contributing

        Development setup, code style, git workflow, and pull request guidelines.

        Contributing Coming soon

      "},{"location":"docs/#platform-at-a-glance","title":"Platform at a Glance","text":"Component Technology Purpose Main API Express.js + Prisma Auth, campaigns, map, shifts, pages, email Media API Fastify + Prisma Video library, analytics, upload, scheduling Admin GUI React + Ant Design + Zustand Dashboard for admins and organizers Database PostgreSQL 16 Single shared database for both APIs Cache Redis Rate limiting, BullMQ jobs, geocoding queue Proxy Nginx Subdomain routing, security headers, SSL Tunnel Pangolin + Newt Expose services without port forwarding Monitoring Prometheus + Grafana Metrics, dashboards, alerts

      New here?

      Start with the Getting Started guide to have the platform running in under 30 minutes.

      Looking for the source?

      Changemaker Lite is 100% open source. Browse the code on Gitea.

      "},{"location":"docs/phil/","title":"Philosophy","text":""},{"location":"docs/phil/#software-is-political","title":"Software Is Political","text":"

      Every tool your movement adopts shapes how you organize. Proprietary platforms reinforce hierarchy \u2014 the vendor decides what features you get, what data you can export, and what happens when you stop paying. Community-controlled tools support democratic autonomy because the people using them decide how they work.

      If you do politics, who is reading your secrets? Corporate platforms harvest political intelligence systematically. Facebook chat data has been used in criminal prosecutions. Social media platforms are leveraged for political coordination and surveillance. When you organize on corporate infrastructure, you hand your strategies, your voter data, and your movement's internal conversations to entities that may have every reason to work against you.

      Changemaker Lite exists because we believe organizational independence requires technological independence.

      "},{"location":"docs/phil/#the-extractive-model","title":"The Extractive Model","text":"

      Most campaign and political software is extractive by design. The pattern is familiar:

      1. Free trial hooks you in
      2. Paid features gate the tools you actually need
      3. Data export becomes difficult or impossible
      4. Pricing escalates as you grow and become dependent
      5. Your usage patterns are monetized through data partnerships, behavioral analytics, and enterprise contracts

      This isn't a side effect \u2014 it's the business model. You pay with money and with data. Your voter lists, canvassing outcomes, donor records, and communication patterns become assets on someone else's balance sheet.

      Every subscription to corporate software funds the machine you're fighting.

      "},{"location":"docs/phil/#the-alternative-grow-power-dont-rent-it","title":"The Alternative: Grow Power, Don't Rent It","text":"

      Changemaker asks a different question than most political tech: instead of \"how do we extract more data from a community?\" we ask \"what tools are needed to grow change in a community?\"

      Growing change means:

      • Making real connections between organizers, volunteers, and community members \u2014 not just collecting their contact info
      • Providing access to the same caliber of tools that well-funded campaigns use \u2014 without the price tag or the surveillance
      • Deeply understanding the wants and needs of your movement \u2014 on infrastructure you control, with data you own
      "},{"location":"docs/phil/#distributed-organizing-is-the-way-out","title":"Distributed Organizing Is The Way Out","text":"

      Socialist movements will never outspend capital. Progressive organizations cannot compete financially with well-funded conservative movements, and chasing big-donor dollars leads to mission drift and organizational capture \u2014 what some call the Political Industrial Complex.

      A thousand neighborhood mailing lists has more potential impact than any single organization. When organizing knowledge and digital tools are widely distributed \u2014 not gatekept by leadership or locked behind vendor paywalls \u2014 movements become genuinely resilient.

      The historical pattern is clear: worker victories occurred when organizing knowledge was widely distributed, not concentrated at the top. Changemaker Lite is built on this premise \u2014 provide the tools freely, train people to use them, and get out of the way.

      Workers, with the right tools, will build the future.

      "},{"location":"docs/phil/#de-corp-your-stack","title":"De-Corp Your Stack","text":"

      The practical work of digital sovereignty starts with replacing corporate services one at a time:

      Corporate Tool Changemaker Alternative What You Gain Mailchimp Listmonk Unlimited subscribers, no per-send charges, your data stays local NationBuilder Changemaker Lite Full campaign platform without the $50-500/month ransom Google Docs Gitea + Code Server Version control, collaboration, no algorithmic scanning Slack Rocket.Chat Team chat with SSO, no message limits, no corporate eavesdropping SurveyMonkey Response Wall Supporter voices on your terms, with moderation you control Google Maps Self-hosted Leaflet No API fees, no tracking, offline-capable canvassing

      The cost reduction is dramatic. Organizations spending thousands monthly on SaaS tools can replace them with a single self-hosted server running Changemaker Lite for roughly the cost of hosting \u2014 often under $50/month.

      But the real value isn't cost savings. It's control. No vendor can cut off your access. No acquisition can change your terms. No government can compel a foreign company to hand over your data. Your movement's digital infrastructure belongs to your movement.

      "},{"location":"docs/phil/#security-culture-starts-with-infrastructure","title":"Security Culture Starts With Infrastructure","text":"

      Security culture isn't just about who knows what \u2014 it's about who can know what. When your communications run through corporate servers, you've made a structural decision about who has access before you've even thought about operational security.

      Key principles:

      • Compartmentalization by design \u2014 Self-hosted systems let you control exactly who has access to what, at the infrastructure level
      • No third-party access \u2014 No corporate subpoenas for your data, no partnership agreements sharing your information
      • Audit everything \u2014 When you run the servers, you can verify that your security promises are real, not just marketing
      • Consent and autonomy \u2014 Your community sets its own security boundaries rather than accepting whatever a vendor's privacy policy allows

      You wouldn't hold a sensitive strategy meeting in a room wired by someone else. Why would you plan your campaign on someone else's servers?

      "},{"location":"docs/phil/#our-principles","title":"Our Principles","text":"

      Liberation First Technology should center marginalized voices. The tools we build reflect the values we hold, and they shape the movements that use them.

      Community Over Profit Changemaker Lite is free and open source software, built by a cooperative \u2014 not a startup looking for an exit. There are no shareholders to satisfy, no venture capitalists to answer to. The software serves the community because that's the only thing it's designed to do.

      Data Sovereignty Communities should own their complete digital infrastructure. Not just the content \u2014 the servers, the databases, the encryption keys, and the ability to pack up and leave at any time.

      Radical Accessibility Self-hosted doesn't have to mean self-excluding. Changemaker Lite is designed for organizers, not sysadmins. If you can follow a guide to set up a WordPress site, you can run this platform.

      "},{"location":"docs/phil/#further-reading","title":"Further Reading","text":"

      These articles explore the ideas behind Changemaker Lite in depth:

      • If You Do Politics, Who Is Reading Your Secrets? \u2014 Why you should de-corp your software stack
      • Distributed Digital Organizing Is The Way Out \u2014 Why decentralized power structures outperform centralized ones
      • How Not To Get Got Making Content \u2014 Platform independence for political content creators
      • What Is Security Culture? \u2014 The foundations of security culture for movements
      "},{"location":"docs/admin/","title":"Admin Guide","text":"

      The admin panel at /app is your command center for managing the entire platform. Use the sidebar to navigate between modules, or press Ctrl+K to open the command palette for quick access to any page, setting, or action.

      "},{"location":"docs/admin/#sections","title":"Sections","text":"
      • Dashboard

        Live overview of platform activity, upcoming shifts, email stats, and service health.

      • People & Access

        User management, roles, the People CRM, and contact merging.

      • Advocacy

        Campaigns, response moderation, representative lookup, and email queue monitoring.

      • Broadcast

        Newsletter sync, email templates, and SMS campaigns.

      • Web Content

        Landing pages, homepage, navigation menu, and documentation management.

      • Map & Canvassing

        Locations, areas, shifts, canvassing dashboard, data quality, and map settings.

      • Media

        Video/photo library, analytics, playlists, comment moderation, and gallery ads.

      • Payments

        Products, donations, subscription plans, and Stripe configuration.

      • Services

        Tunnel management, monitoring, and third-party integrations.

      • Settings

        Organization branding, theme colors, email config, feature toggles, and notifications.

      "},{"location":"docs/admin/#roles-reference","title":"Roles Reference","text":"Role Access Level SUPER_ADMIN Full platform access INFLUENCE_ADMIN Campaigns, responses, email queue MAP_ADMIN Locations, areas, shifts, canvassing USER Volunteer portal only TEMP Limited volunteer access (auto-created)"},{"location":"docs/admin/dashboard/","title":"Dashboard","text":"

      The admin dashboard (/app) provides a real-time overview of platform activity.

      "},{"location":"docs/admin/dashboard/#dashboard-cards","title":"Dashboard Cards","text":"
      • Platform Stats \u2014 active campaigns, total emails sent, registered users, and location count
      • Activity Feed \u2014 recent events across all modules (signups, emails, visits, responses)
      • Upcoming Shifts \u2014 next scheduled volunteer shifts with signup counts
      • Newsletter Stats \u2014 Listmonk subscriber counts and recent campaign performance
      • Chat Activity \u2014 Rocket.Chat channel activity and online users (when chat is enabled)
      • Service Health \u2014 connectivity status for integrated services (Gitea, Gancio, Vaultwarden, etc.)

      All cards auto-refresh and gracefully degrade when their associated module is disabled.

      "},{"location":"docs/admin/people-access/","title":"People & Access","text":"

      Manage platform user accounts and roles, and use the People CRM to get a unified view of every supporter, donor, and volunteer across all modules.

      "},{"location":"docs/admin/people-access/#user-management","title":"User Management","text":""},{"location":"docs/admin/people-access/#creating-users","title":"Creating Users","text":"

      Navigate to Users (/app/users) and click Add User. Fill in name, email, and role. The user will receive a welcome email with login instructions.

      "},{"location":"docs/admin/people-access/#roles","title":"Roles","text":"Role Access Use Case SUPER_ADMIN Full platform access Campaign managers INFLUENCE_ADMIN Campaigns, responses, email queue Advocacy coordinators MAP_ADMIN Locations, areas, shifts, canvassing Field organizers USER Volunteer portal only Active volunteers TEMP Limited volunteer access Shift signups (auto-created)"},{"location":"docs/admin/people-access/#password-policy","title":"Password Policy","text":"

      Passwords must be at least 12 characters with uppercase, lowercase, and a digit. This is enforced at the API schema level.

      "},{"location":"docs/admin/people-access/#deactivating-users","title":"Deactivating Users","text":"

      Edit a user from the Users page and toggle their active status. Deactivated users cannot log in but their data is preserved. Banned users have their sessions invalidated immediately.

      "},{"location":"docs/admin/people-access/#service-accounts-panel","title":"Service Accounts Panel","text":"

      When editing a user, the Service Accounts panel shows provisioning status for each integrated service (Rocket.Chat, Gitea, Vaultwarden, Listmonk). You can provision, deprovision, or re-sync individual services per user.

      "},{"location":"docs/admin/people-access/#people-crm","title":"People CRM","text":"

      Enable with enablePeople in Settings. The People module serves as the platform's CRM, aggregating data from all other modules into a unified view.

      "},{"location":"docs/admin/people-access/#virtual-aggregation","title":"Virtual Aggregation","text":"

      The People page does not store a separate \"people\" table. Instead, it aggregates records in real time from seven data sources:

      Source Data Users Platform accounts (name, email, phone, last login) Address Occupants Named residents from the map/canvassing module Campaign Senders People who sent advocacy emails Shift Signups Volunteer shift registrants SMS Contacts Contacts from SMS campaign lists Donations/Orders Buyers from the payments module Manual Contacts created directly in the CRM

      Records are deduplicated by normalized email or phone number, with Users taking highest priority.

      "},{"location":"docs/admin/people-access/#managed-contacts","title":"Managed Contacts","text":"

      Any virtual person can be \"promoted\" to a managed Contact record. This creates a persistent Contact entity in the database with:

      • Display name, first/last name \u2014 editable independently of the source
      • Tags \u2014 custom CRM tags for segmentation and filtering
      • Notes \u2014 free-text notes field
      • Support level \u2014 LEVEL_1 (Strong) through LEVEL_4 (Opposition)
      • Opt-out flags \u2014 email opt-out, SMS opt-out, and do-not-contact
      • Sign requested \u2014 track yard sign status
      "},{"location":"docs/admin/people-access/#contact-details","title":"Contact Details","text":"

      Each managed contact supports multiple structured data entries:

      • Addresses \u2014 link to map locations with optional unit numbers and primary flag; new addresses can be auto-added to the map for geocoding
      • Emails \u2014 multiple email addresses with labels (e.g., Personal, Work) and primary designation
      • Phones \u2014 multiple phone numbers with labels and primary designation
      "},{"location":"docs/admin/people-access/#activity-timeline","title":"Activity Timeline","text":"

      View a chronological timeline of all interactions for a person, across every module:

      • Advocacy emails sent and responses submitted
      • Shift signups and canvass visits
      • Donations and product purchases
      • SMS messages sent and received
      • Video views
      • Notes added and contact merges
      "},{"location":"docs/admin/people-access/#duplicate-detection-and-merge","title":"Duplicate Detection and Merge","text":"

      The platform identifies potential duplicates by matching normalized email addresses and phone numbers across sources. The merge workflow lets you:

      • Select which fields to keep from the source vs. target contact
      • Merge tags, addresses, emails, and phones
      • Preserve the full audit trail (merged contacts are soft-linked, not deleted)
      "},{"location":"docs/admin/people-access/#connection-graph","title":"Connection Graph","text":"

      Build a relationship graph between contacts using typed connections:

      • Connection types \u2014 Household, Family, Colleague, Referred By, and Custom
      • Bidirectional \u2014 connections can be one-way or mutual
      • Visual graph \u2014 interactive force-directed graph visualization showing contacts as nodes and connections as edges
      • Configurable depth \u2014 explore up to 3 degrees of separation
      "},{"location":"docs/admin/people-access/#profile-links","title":"Profile Links","text":"

      Generate shareable public profile pages for contacts:

      • Unique token URLs at /profile/:token
      • Configurable expiration \u2014 24 hours, 7 days, 30 days, 90 days, 1 year, or never
      • Optional password protection \u2014 require a PIN or password to view
      "},{"location":"docs/admin/people-access/#household-detection","title":"Household Detection","text":"

      The Household panel groups contacts who share the same physical address, making it easy to see all members of a household and their combined engagement.

      "},{"location":"docs/admin/people-access/#create-user-from-contact","title":"Create User from Contact","text":"

      Promote a CRM contact to a full platform user account directly from the People interface, with role assignment and optional welcome email.

      "},{"location":"docs/admin/people-access/#admin-routes","title":"Admin Routes","text":"
      • /app/users \u2014 user CRUD, role assignment, service accounts
      • /app/people \u2014 contact list with search, filters, source/tag filtering, and bulk actions
      "},{"location":"docs/admin/settings/","title":"Platform Settings","text":"

      Centralized configuration for organization identity, theming, email delivery, feature modules, and automated notifications.

      "},{"location":"docs/admin/settings/#settings-tabs","title":"Settings Tabs","text":""},{"location":"docs/admin/settings/#organization","title":"Organization","text":"

      Configure your organization's public identity:

      • Organization Name \u2014 displayed in the admin sidebar, public pages, and emails
      • Short Name \u2014 shown when the admin sidebar is collapsed (max 10 characters)
      • Logo URL \u2014 displayed on the login page, homepage hero, and public navigation
      • Favicon URL \u2014 browser tab icon
      • Footer Text \u2014 shown in public page footers
      • Login Subtitle \u2014 displayed below the organization name on the login page
      "},{"location":"docs/admin/settings/#theme-colors","title":"Theme Colors","text":"

      Customize the look of admin and public interfaces:

      Admin theme:

      • Primary color (accent color for buttons, links, active states)
      • Background color (page background)

      Public theme:

      • Primary color
      • Background color
      • Container color (card and section backgrounds)
      • Header gradient (CSS gradient string for the public navigation bar)

      A live preview panel shows color swatches and a gradient preview as you configure.

      "},{"location":"docs/admin/settings/#email","title":"Email","text":"

      Configure how the platform sends emails:

      • Sender \u2014 from name and from address for all outgoing emails
      • Active SMTP provider \u2014 toggle between MailHog (testing) and Production SMTP with a single click
      • Production SMTP \u2014 host, port, username, and password (collapsible panel, disabled when MailHog is active)
      • Test mode \u2014 when enabled, all emails redirect to a single test recipient address
      • Test actions \u2014 \"Test Connection\" verifies SMTP connectivity; \"Send Test Email\" delivers a test message through the active provider

      A configuration summary card at the top displays the current provider, server, authentication status, and test mode state.

      "},{"location":"docs/admin/settings/#feature-toggles","title":"Feature Toggles","text":"

      Enable or disable platform modules. Disabling a module hides it from navigation but does not delete data.

      Category Flag Description Core Platform enableInfluence Advocacy campaigns, email sending, response wall enableMap Map, locations, canvassing, volunteer shifts enableNewsletter Listmonk newsletter sync enableLandingPages GrapesJS landing page builder Media & Content enableMediaFeatures Video library, public gallery, analytics enableGalleryAds Promotional cards in the video gallery enableEvents Gancio event calendar integration Communication enableChat Rocket.Chat team coordination enableMeet Jitsi video meetings (integrates with Rocket.Chat) enableSms Termux Android SMS campaigns People & Engagement enablePeople Unified contacts CRM enableSocial Volunteer social connections and activity feeds autoSyncPeopleToMap Auto-create map locations from contact addresses Commerce enablePayments Stripe subscriptions, products, and donations"},{"location":"docs/admin/settings/#notifications","title":"Notifications","text":"

      Control which automated email notifications the platform sends. Disabling a notification stops future emails but does not affect already-queued jobs.

      Admin alerts:

      • New shift signup
      • Response wall submission
      • Yard sign request (from canvassing)
      • Shift cancellation

      Volunteer emails:

      • Canvass session summary (sent after completing a session)
      • Signup cancellation confirmation
      • 24-hour pre-shift reminder
      • Post-shift thank-you (sent 2 hours after shift ends)

      Re-engagement:

      • Re-engagement emails for inactive volunteers
      • Configurable inactivity threshold (days without activity)
      • Configurable cooldown period (minimum days between re-engagement emails)
      "},{"location":"docs/admin/settings/#admin-routes","title":"Admin Routes","text":"
      • /app/settings \u2014 multi-tab settings page (supports deep-linking to a specific tab via router state)
      "},{"location":"docs/admin/advocacy/","title":"Advocacy","text":"

      The advocacy module helps supporters contact their elected representatives through email campaigns.

      "},{"location":"docs/admin/advocacy/#in-this-section","title":"In This Section","text":"
      • Campaigns \u2014 create and publish advocacy campaigns with postal code lookup and response tracking
      • Responses \u2014 moderate the public response wall where supporters share representative replies
      • Representatives \u2014 manage the representative lookup cache powered by the Represent API
      • Email Queue \u2014 monitor outgoing advocacy emails, retry failures, and view delivery stats
      "},{"location":"docs/admin/advocacy/campaigns/","title":"Advocacy Campaigns","text":"

      Help supporters contact their elected representatives through email campaigns.

      "},{"location":"docs/admin/advocacy/campaigns/#how-it-works","title":"How It Works","text":"
      1. An admin creates a campaign \u2014 writes the email subject and body, selects which government levels to target (federal, provincial, municipal, school board), and publishes it.
      2. A supporter visits the campaign page \u2014 enters their postal code to look up their representatives.
      3. The supporter sends the email \u2014 either directly through the platform (\"Send Now\") or by opening it in their own email app (Gmail, Outlook, etc.).
      4. Responses get tracked \u2014 supporters and admins can share representative responses on the Response Wall, with upvoting and moderation.
      "},{"location":"docs/admin/advocacy/campaigns/#key-features","title":"Key Features","text":"
      • Postal code lookup \u2014 powered by the Represent API, returns representatives at all government levels
      • Two send methods \u2014 server-sent SMTP (tracked) or mailto link (opens user's email app)
      • Email editing \u2014 optionally let supporters personalize the email before sending
      • Response Wall \u2014 public wall where people share how their representatives responded, with moderation and verification
      • Campaign stats \u2014 track emails sent, responses received, and upvotes
      • Featured campaigns \u2014 highlight important campaigns on the public listing page
      "},{"location":"docs/admin/advocacy/campaigns/#user-submitted-campaigns","title":"User-Submitted Campaigns","text":"

      Registered (non-temporary) users can create their own advocacy campaigns and submit them for admin review.

      • Public submission route \u2014 users visit /campaigns/create to draft a campaign through a guided wizard
      • 3-step wizard \u2014 the submission flow walks users through campaign details (title, description, government levels), email template (subject and body), and a final review step before submitting
      • My campaigns dashboard \u2014 users can view and manage their submitted campaigns at /campaigns/mine, including checking moderation status and editing campaigns that have been sent back for changes
      • Restricted fields \u2014 user-submitted campaigns have limited options compared to admin-created ones (no SMTP sending, no highlight, no custom recipients); only the mailto link fallback is enabled by default
      • Auto-moderation status \u2014 newly submitted campaigns start in PENDING_REVIEW status and remain in DRAFT until an admin approves them
      • Edit restrictions \u2014 users can only edit their own campaigns, and only when the moderation status is PENDING_REVIEW or CHANGES_REQUESTED; editing automatically resets the status back to PENDING_REVIEW
      • Rate limiting \u2014 campaign submissions are rate-limited to 5 per hour per IP to prevent abuse
      • XSS protection \u2014 all user-supplied text (title, description, email subject, email body) is HTML-escaped before storage
      "},{"location":"docs/admin/advocacy/campaigns/#campaign-moderation","title":"Campaign Moderation","text":"

      Admins review user-submitted campaigns before they go live.

      • Moderation queue \u2014 accessible at /app/campaign-moderation, showing all user-generated campaigns filtered by moderation status (pending, approved, rejected, changes requested)
      • Moderation actions \u2014 for each campaign in the queue, admins can:
        • Approve \u2014 sets the moderation status to APPROVED and the campaign status to ACTIVE, making it publicly visible
        • Reject \u2014 marks the campaign as REJECTED with an optional reason visible to the submitter
        • Request changes \u2014 sets the status to CHANGES_REQUESTED with feedback, allowing the user to revise and resubmit
      • Moderation stats \u2014 the queue page displays counters for total user-generated campaigns, pending reviews, approved, rejected, and changes-requested counts
      • Reviewer tracking \u2014 each moderation action records the reviewer's user ID and timestamp
      • Search and filter \u2014 the moderation queue supports searching by campaign title, submitter name, or email, and filtering by moderation status
      "},{"location":"docs/admin/advocacy/campaigns/#campaign-analytics","title":"Campaign Analytics","text":"

      The Campaign Effectiveness dashboard provides cross-campaign performance analytics at /app/influence/effectiveness.

      • Performance tab \u2014 per-campaign KPIs including total emails sent, email delivery status breakdown, response counts, response rates, and call counts; top campaigns visualized as a horizontal bar chart
      • Representatives tab \u2014 tracks individual representative responsiveness across all campaigns; shows emails received, responses given, verified response count, and response rate per representative; sortable by response count, response rate, or name; includes government level distribution
      • Geography tab \u2014 engagement breakdown by geographic area; group results by postal code, city, or province; enriched with city/province data from the postal code cache
      • Funnel tab \u2014 conversion funnel visualization showing progression from emails sent to unique participants to responses received to verified responses, plus calls made; includes percentage-of-first and stage-to-stage dropoff rates
      • Trends tab \u2014 time-series activity chart showing daily or weekly email and response volumes; default view covers the last 30 days; merged email and response series for side-by-side comparison
      • Global filters \u2014 all tabs share campaign and date range filters; select a specific campaign or view aggregate data across all campaigns
      "},{"location":"docs/admin/advocacy/campaigns/#admin-routes","title":"Admin Routes","text":"
      • /app/campaigns \u2014 create, edit, and manage campaigns
      • /app/campaign-moderation \u2014 review and moderate user-submitted campaigns
      • /app/influence/effectiveness \u2014 campaign effectiveness analytics dashboard
      • /app/responses \u2014 moderate submitted responses
      • /app/email-queue \u2014 monitor outgoing email delivery
      "},{"location":"docs/admin/advocacy/campaigns/#public-routes","title":"Public Routes","text":"
      • /campaigns \u2014 browse active campaigns
      • /campaigns/create \u2014 submit a new user-generated campaign (requires login)
      • /campaigns/mine \u2014 view and manage your submitted campaigns (requires login)
      • /campaign/:slug \u2014 take action on a specific campaign
      • /campaign/:slug/responses \u2014 view the response wall
      "},{"location":"docs/admin/advocacy/email-queue/","title":"Email Queue","text":"

      Monitor outgoing advocacy emails processed through the BullMQ queue.

      "},{"location":"docs/admin/advocacy/email-queue/#key-features","title":"Key Features","text":"
      • Queue dashboard \u2014 view pending, active, completed, and failed jobs at /app/email-queue
      • Job details \u2014 inspect individual email jobs with recipient, subject, status, and timestamps
      • Retry failed jobs \u2014 re-queue emails that failed due to SMTP errors or timeouts
      • Clear completed \u2014 bulk-remove completed jobs to keep the queue clean
      • Stats \u2014 total sent, delivery rate, and average processing time
      "},{"location":"docs/admin/advocacy/email-queue/#admin-routes","title":"Admin Routes","text":"
      • /app/email-queue \u2014 email queue monitoring and management
      "},{"location":"docs/admin/advocacy/representatives/","title":"Representatives","text":"

      The platform uses the Represent API to look up elected representatives by postal code across all government levels.

      "},{"location":"docs/admin/advocacy/representatives/#how-it-works","title":"How It Works","text":"
      • Postal code lookup \u2014 enter a Canadian postal code to retrieve federal, provincial, municipal, and school board representatives
      • Redis cache \u2014 lookup results are cached to reduce API calls and improve response times
      • Cache management \u2014 view cache status and clear entries from /app/representatives
      • Government levels \u2014 campaigns can target specific levels (federal, provincial, municipal, school board)
      "},{"location":"docs/admin/advocacy/representatives/#admin-routes","title":"Admin Routes","text":"
      • /app/representatives \u2014 representative cache management and lookup testing
      "},{"location":"docs/admin/advocacy/responses/","title":"Response Moderation","text":"

      Review and moderate representative responses submitted by supporters on the public response wall.

      "},{"location":"docs/admin/advocacy/responses/#key-features","title":"Key Features","text":"
      • Moderation queue \u2014 review submissions at /app/responses with filtering by campaign and status
      • Verification \u2014 mark responses as verified to display a trust badge on the public wall
      • Upvoting \u2014 supporters can upvote responses; counts are visible on the public wall
      • Approve / reject \u2014 control which responses appear publicly
      • Response stats \u2014 track response counts per campaign and per representative
      "},{"location":"docs/admin/advocacy/responses/#admin-routes","title":"Admin Routes","text":"
      • /app/responses \u2014 response moderation dashboard
      "},{"location":"docs/admin/broadcast/","title":"Broadcast","text":"

      Reach supporters through multiple channels \u2014 email newsletters, templated campaigns, and SMS text messages.

      "},{"location":"docs/admin/broadcast/#in-this-section","title":"In This Section","text":"
      • Newsletter \u2014 Listmonk integration with automatic subscriber sync from shifts, campaigns, and contacts
      • Email Templates \u2014 reusable templates with variable substitution, version history, and test sending
      • SMS \u2014 text message campaigns via a Termux Android bridge with contact lists and response tracking
      "},{"location":"docs/admin/broadcast/email-templates/","title":"Email Templates","text":"

      Create reusable email templates with variable substitution for campaign communications. Templates are used by advocacy campaigns, shift confirmations, volunteer re-engagement emails, and other automated communications.

      "},{"location":"docs/admin/broadcast/email-templates/#template-categories","title":"Template Categories","text":"

      Each template belongs to a category that determines where it can be used:

      • INFLUENCE -- advocacy campaign emails sent to representatives
      • MAP -- shift confirmation, volunteer thank-you, and canvassing-related emails
      • SYSTEM -- account verification, password reset, and platform notifications
      • PAYMENT -- donation receipts, subscription confirmations, and purchase orders
      "},{"location":"docs/admin/broadcast/email-templates/#variable-system","title":"Variable System","text":"

      Templates use Handlebars-style {{VARIABLE_NAME}} placeholders that are replaced at send time. Variables must use uppercase letters and underscores (e.g., {{RECIPIENT_NAME}}, {{CAMPAIGN_TITLE}}).

      • Text variables -- simple string substitution for names, dates, URLs, and other text
      • Video variables -- embed a media library video by referencing its ID
      • Conditional blocks -- show or hide content with {{#if VARIABLE}}...{{/if}} syntax
      • Required vs optional -- each variable can be marked as required, with sample values for test emails

      The template validator automatically extracts all variables from the HTML, text, and subject line content and checks for unmatched conditional blocks.

      "},{"location":"docs/admin/broadcast/email-templates/#version-history","title":"Version History","text":"

      Every change to a template's subject line, HTML content, or text content creates a new version. The full version history is preserved, and any previous version can be restored:

      • Version browsing -- view the subject, HTML, and text content of any past version
      • Rollback -- restore a previous version (creates a new version entry, preserving the audit trail)
      • Change notes -- each version includes a description of what changed
      • Author tracking -- versions record which admin made each change
      "},{"location":"docs/admin/broadcast/email-templates/#test-emails","title":"Test Emails","text":"

      Before activating a template, send a test email to verify rendering:

      • Variable substitution -- provide test data for each variable to preview the final output
      • Recipient selection -- send the test to any email address
      • Test log -- all test sends are logged with success/failure status and message IDs
      • Rate limited -- 10 test emails per 15 minutes per user to prevent abuse
      "},{"location":"docs/admin/broadcast/email-templates/#template-caching","title":"Template Caching","text":"

      Rendered templates are cached in memory for performance. The cache is automatically cleared when a template is created, updated, or deleted. Admins can also manually clear the cache from the admin interface.

      "},{"location":"docs/admin/broadcast/email-templates/#admin-routes","title":"Admin Routes","text":"
      • /app/email-templates -- create and manage email templates with a visual editor
      "},{"location":"docs/admin/broadcast/newsletter/","title":"Newsletter (Listmonk)","text":"

      Integrated with Listmonk for opt-in mailing lists and newsletter campaigns. Enable with LISTMONK_SYNC_ENABLED=true.

      "},{"location":"docs/admin/broadcast/newsletter/#managed-lists","title":"Managed Lists","text":"

      The platform automatically creates and maintains 13 subscriber lists in Listmonk:

      List Name Source Tags All Contacts All synced records v2 Campaign Participants Users who sent advocacy emails v2, influence Locations - All Address occupants with email v2, map Support Level 1-4 Addresses by canvass support level v2, map, support Has Campaign Sign Addresses with a yard sign v2, map, signs Users Active non-temp platform accounts v2, users Volunteers Shift signups v2, map, shifts Canvassers Users who completed canvass sessions v2, map, canvass Subscribers Active paid subscribers v2, payments Donors Users who completed a donation v2, payments"},{"location":"docs/admin/broadcast/newsletter/#bulk-sync","title":"Bulk Sync","text":"

      The admin panel provides a manual \"Sync All\" action that synchronizes four data sources to Listmonk:

      1. Campaign participants -- distinct email senders from advocacy campaigns
      2. Location contacts -- address occupants with email, mapped to support level and sign lists
      3. Users -- active platform accounts (excludes TEMP users)
      4. CRM tags -- contacts tagged in the People module, synced to tag-linked Listmonk lists

      Each source upserts subscribers (creates new or merges into existing), preserving existing list memberships and merging metadata attributes.

      "},{"location":"docs/admin/broadcast/newsletter/#event-driven-sync","title":"Event-Driven Sync","text":"

      In addition to bulk sync, the platform fires real-time subscriber upserts on application events:

      • Shift signup -- adds to Volunteers list
      • Canvass session completed -- adds to Canvassers list
      • Campaign email sent -- adds to Campaign Participants list
      • Subscription activated -- adds to Subscribers list
      • Donation completed -- adds to Donors list
      • Product purchased -- adds to Donors list
      • Address updated (canvass visit) -- updates support level list membership
      • Re-engagement email sent -- updates Volunteers list metadata
      • CRM tag changed -- adds/removes from tag-linked Listmonk lists

      All event-driven syncs are fire-and-forget and silently fail if Listmonk is unreachable.

      "},{"location":"docs/admin/broadcast/newsletter/#admin-routes","title":"Admin Routes","text":"
      • /app/listmonk (sidebar: \"Newsletter\") -- sync status, subscriber counts, campaign stats, and manual sync trigger
      "},{"location":"docs/admin/broadcast/sms/","title":"SMS Campaigns","text":"

      Text message outreach via a Termux Android bridge. Enable with ENABLE_SMS=true.

      "},{"location":"docs/admin/broadcast/sms/#how-it-works","title":"How It Works","text":"
      1. Configure the bridge \u2014 set TERMUX_API_URL and TERMUX_API_KEY to connect to an Android device running Termux with the SMS plugin
      2. Create contact lists \u2014 import contacts or build lists from existing supporters
      3. Write a campaign \u2014 compose a message template with variable substitution (name, location, etc.)
      4. Launch the campaign \u2014 messages are queued via BullMQ and sent serially through the device
      5. Monitor responses \u2014 incoming replies are synced and classified by keyword
      "},{"location":"docs/admin/broadcast/sms/#key-features","title":"Key Features","text":"
      • Contact lists \u2014 import, tag, and segment contacts for targeted outreach
      • Message templates \u2014 reusable templates with variable placeholders
      • BullMQ queue \u2014 serial delivery with configurable delays between messages (SMS_DELAY_BETWEEN_MS)
      • Response sync \u2014 incoming SMS replies synced and classified automatically (SMS_RESPONSE_SYNC_INTERVAL_MS)
      • Device monitoring \u2014 health checks and status reporting for the connected Android device (SMS_DEVICE_MONITOR_INTERVAL_MS)
      • Conversation view \u2014 threaded message history per contact
      • Retry logic \u2014 configurable retry attempts for failed deliveries (SMS_MAX_RETRIES)
      "},{"location":"docs/admin/broadcast/sms/#setup-wizard","title":"Setup Wizard","text":"

      The SMS Setup page (/app/sms/setup) provides a guided three-step wizard for connecting your Android phone:

      "},{"location":"docs/admin/broadcast/sms/#step-1-prepare-phone","title":"Step 1: Prepare Phone","text":"

      Install Termux and Termux:API from F-Droid on the Android device, then generate a shared API key from the admin panel. The key is used for mutual authentication between the server and the phone.

      "},{"location":"docs/admin/broadcast/sms/#step-2-connect","title":"Step 2: Connect","text":"

      Choose one of two connection methods:

      • Tailscale Auto-Discovery (recommended) -- enter your Tailscale API key and the wizard automatically discovers devices on your tailnet, highlights Android devices, and pre-fills the stable IP address
      • Manual URL Entry -- enter the Termux API server URL directly (typically http://100.x.x.x:5001 when using Tailscale)
      "},{"location":"docs/admin/broadcast/sms/#step-3-test-and-save","title":"Step 3: Test and Save","text":"

      Run a live connection test against the phone to verify the URL and API key are correct. The test displays device health info (uptime, messages sent). Once the test passes, save the configuration to enable SMS features platform-wide.

      The wizard stores credentials encrypted in the database and updates the enableSms feature flag automatically.

      "},{"location":"docs/admin/broadcast/sms/#admin-routes","title":"Admin Routes","text":"
      • /app/sms/setup -- guided setup wizard with Tailscale auto-discovery
      • /app/sms -- SMS dashboard with campaign overview and device status
      • /app/sms/contacts -- manage contact lists and entries
      • /app/sms/campaigns -- create and monitor SMS campaigns
      • /app/sms/conversations -- view threaded conversations with contacts
      "},{"location":"docs/admin/map/","title":"Map & Canvassing","text":"

      Manage locations, organize canvassing territories, schedule volunteer shifts, and coordinate door-to-door outreach.

      "},{"location":"docs/admin/map/#in-this-section","title":"In This Section","text":"
      • Locations \u2014 import addresses via CSV or NAR, geocode with multiple providers, and manage the location database
      • Areas \u2014 draw polygon territories on the map to organize canvassing regions
      • Shifts \u2014 schedule volunteer time slots with recurring patterns and calendar views
      • Canvassing \u2014 canvass dashboard, walk sheets, contact export, and session management
      • Data Quality \u2014 geocoding quality metrics, provider distribution, and confidence analysis
      • Map Settings \u2014 configure map center, zoom level, and QR code links
      "},{"location":"docs/admin/map/areas/","title":"Areas (Cuts)","text":"

      Draw polygon regions on the map to define canvassing territories. Areas organize locations into manageable chunks for volunteers.

      "},{"location":"docs/admin/map/areas/#key-features","title":"Key Features","text":"
      • Polygon drawing \u2014 use the map editor at /app/map/cuts to draw, edit, and delete area boundaries
      • Automatic association \u2014 locations within an area's polygon boundary are automatically linked
      • Area stats \u2014 total addresses, visited count, coverage percentage per area
      • Color coding \u2014 assign colors to visually distinguish areas on the map
      "},{"location":"docs/admin/map/areas/#area-import-wizard","title":"Area Import Wizard","text":"

      Bulk-import addresses into an area from multiple data sources:

      • OpenStreetMap (OSM) \u2014 pull building addresses from Nominatim within the area
      • NAR (National Address Register) \u2014 import from the Canadian federal address dataset
      • Reverse geocode grid \u2014 generate a grid of points and reverse-geocode to discover addresses
      • Deduplication \u2014 imported addresses are checked against existing locations to avoid duplicates
      • Progress tracking \u2014 real-time status per source during import
      "},{"location":"docs/admin/map/areas/#admin-routes","title":"Admin Routes","text":"
      • /app/map/cuts \u2014 draw and manage canvassing areas
      • /app/map/cuts/:id/export \u2014 printable location report for a cut
      "},{"location":"docs/admin/map/canvassing/","title":"Canvassing","text":"

      Coordinate and monitor volunteer door-to-door outreach with the canvass dashboard, walk sheets, and contact export tools.

      "},{"location":"docs/admin/map/canvassing/#canvass-dashboard","title":"Canvass Dashboard","text":"

      From /app/map/canvass:

      • Active sessions \u2014 see which volunteers are currently canvassing and their real-time positions
      • Leaderboard \u2014 volunteer rankings by visit count
      • Activity feed \u2014 recent visit outcomes across all areas
      • Stats \u2014 total sessions, visits recorded, and outcome breakdowns
      "},{"location":"docs/admin/map/canvassing/#walk-sheets-exports","title":"Walk Sheets & Exports","text":"
      • Walk sheet \u2014 printable form at /app/map/walk-sheet with space for recording visit outcomes; includes up to 3 configurable QR codes
      • Cut export \u2014 printable location report at /app/map/cuts/:id/export for a specific canvassing area
      "},{"location":"docs/admin/map/canvassing/#canvass-contact-export","title":"Canvass Contact Export","text":"

      Bridge canvassing data with advocacy campaigns:

      • Filter by outcome \u2014 include specific visit outcomes (spoke with, left literature, come back later)
      • Support level range \u2014 filter by recorded support level
      • Area selection \u2014 limit export to specific areas
      • Campaign targeting \u2014 export contacts as recipients for an advocacy campaign
      "},{"location":"docs/admin/map/canvassing/#admin-routes","title":"Admin Routes","text":"
      • /app/map/canvass \u2014 canvass dashboard
      • /app/map/walk-sheet \u2014 printable walk sheet
      "},{"location":"docs/admin/map/data-quality/","title":"Data Quality","text":"

      Monitor geocoding accuracy and coverage from /app/map/data-quality.

      "},{"location":"docs/admin/map/data-quality/#key-metrics","title":"Key Metrics","text":"
      • Geocoding success rate \u2014 percentage of locations with valid coordinates
      • Provider distribution \u2014 breakdown of which geocoding provider was used per location
      • Confidence scores \u2014 distribution of geocoding confidence levels across the dataset
      • Missing data \u2014 locations without coordinates, postal codes, or province assignments
      • Bulk re-geocode \u2014 re-process failed or low-confidence locations with a different provider
      "},{"location":"docs/admin/map/data-quality/#admin-routes","title":"Admin Routes","text":"
      • /app/map/data-quality \u2014 geocoding quality dashboard
      "},{"location":"docs/admin/map/locations/","title":"Locations","text":"

      Import addresses via CSV or the Canadian National Address Register (NAR), geocode them with multiple providers, and manage the location database.

      "},{"location":"docs/admin/map/locations/#adding-locations","title":"Adding Locations","text":"
      • Click-to-add \u2014 click on the admin map to drop a new location marker
      • Form entry \u2014 manually enter address details
      • CSV import \u2014 upload a CSV with address columns; the system geocodes each row
      • NAR import \u2014 import Canadian National Address Register data with province, city, postal code, and residential-only filters
      "},{"location":"docs/admin/map/locations/#geocoding","title":"Geocoding","text":"

      Locations are geocoded automatically using a multi-provider system supporting Nominatim, ArcGIS, Photon, Mapbox, Google, and LocationIQ. Failed entries can be re-geocoded individually or in bulk.

      "},{"location":"docs/admin/map/locations/#bulk-operations","title":"Bulk Operations","text":"

      Select multiple locations for:

      • Re-geocoding with a different provider
      • Tagging or re-tagging
      • Deletion
      • CSV export
      "},{"location":"docs/admin/map/locations/#admin-routes","title":"Admin Routes","text":"
      • /app/map \u2014 location CRUD, CSV import/export, geocoding, area import wizard
      "},{"location":"docs/admin/map/settings/","title":"Map Settings","text":"

      Configure the default map view and QR code links from /app/map/settings.

      "},{"location":"docs/admin/map/settings/#settings","title":"Settings","text":"
      • Map center \u2014 latitude and longitude for the default map center point
      • Default zoom \u2014 initial zoom level when maps load (1-18)
      • QR code links \u2014 up to 3 configurable URLs that appear as QR codes on printed walk sheets (e.g., campaign page, shift signup, volunteer portal)
      "},{"location":"docs/admin/map/settings/#admin-routes","title":"Admin Routes","text":"
      • /app/map/settings \u2014 map configuration
      "},{"location":"docs/admin/map/shifts/","title":"Shifts","text":"

      Schedule volunteer time slots and let people sign up through a public page. Shifts can be linked to specific areas so volunteers know where they'll be canvassing.

      "},{"location":"docs/admin/map/shifts/#creating-shifts","title":"Creating Shifts","text":"
      • Single shifts \u2014 set date, time, location description, and optional area assignment
      • Recurring shifts \u2014 create series with daily, weekly, or monthly frequency; weekly allows specific day selection
      • Calendar view \u2014 dedicated calendar tab showing shifts by date; click any date to create a new shift pre-filled
      "},{"location":"docs/admin/map/shifts/#series-management","title":"Series Management","text":"
      • Edit modes \u2014 when editing a recurring shift, choose: this shift only, this and future, or all in series
      • Date range \u2014 define start and optional end date; generates up to 12 weeks (capped at 100 shifts)
      • Detach \u2014 remove a shift from its series to edit independently
      "},{"location":"docs/admin/map/shifts/#signups","title":"Signups","text":"
      • Signup drawer \u2014 view all signups for a shift in the admin panel
      • Capacity \u2014 optionally set maximum volunteer count per shift
      • Confirmation emails \u2014 automatic email sent when a volunteer signs up or cancels
      "},{"location":"docs/admin/map/shifts/#admin-routes","title":"Admin Routes","text":"
      • /app/map/shifts \u2014 shift CRUD, calendar view, signup management
      "},{"location":"docs/admin/media/","title":"Media","text":"

      Upload, organize, and share campaign videos and photos with built-in analytics and engagement features. Enable with enableMediaFeatures in Settings.

      "},{"location":"docs/admin/media/#in-this-section","title":"In This Section","text":"
      • Library \u2014 upload videos and photos, manage metadata, schedule publishing, and generate preview links
      • Analytics \u2014 view counts, watch time, completion rates, traffic sources, and viewer activity
      • Curated Gallery \u2014 playlists, shorts feed, and featured content for the public gallery
      • Moderation \u2014 comment review, word filters, and content moderation tools
      • Gallery Ads \u2014 promotional cards with audience targeting, scheduling, and click-through analytics
      "},{"location":"docs/admin/media/ads/","title":"Gallery Ads","text":"

      Create promotional cards that appear in the public media gallery and documentation site. Manage from /app/media/ads.

      "},{"location":"docs/admin/media/ads/#key-features","title":"Key Features","text":"
      • Ad CRUD \u2014 create ads with title, description, image, and click-through URL
      • Placement targeting \u2014 assign ads to specific placements (gallery sidebar, gallery feed, docs sidebar)
      • Scheduling \u2014 set start and end dates for time-limited promotions
      • Click tracking \u2014 view impressions and click-through rates per ad
      • Priority ordering \u2014 control which ads appear first when multiple are active
      "},{"location":"docs/admin/media/ads/#admin-routes","title":"Admin Routes","text":"
      • /app/media/ads \u2014 gallery ad management and analytics
      "},{"location":"docs/admin/media/analytics/","title":"Analytics","text":"

      Track video engagement with GDPR-compliant analytics (IP hashing, 90-day retention).

      "},{"location":"docs/admin/media/analytics/#per-video-metrics","title":"Per-Video Metrics","text":"

      Each video tracks:

      • View count and unique viewers
      • Average watch time and completion rate
      • Traffic sources \u2014 direct, embedded, shared
      • Registered viewer activity (when logged in)
      "},{"location":"docs/admin/media/analytics/#global-dashboard","title":"Global Dashboard","text":"

      The analytics dashboard at /app/media/analytics provides:

      • Aggregate view counts across all videos
      • Top-performing content by views and completion rate
      • Viewer trends over time
      • Traffic source breakdown
      "},{"location":"docs/admin/media/analytics/#tracking","title":"Tracking","text":"

      Public endpoints record engagement:

      • View initiation
      • 10-second heartbeat intervals
      • navigator.sendBeacon for reliable end-of-session reporting
      "},{"location":"docs/admin/media/analytics/#admin-routes","title":"Admin Routes","text":"
      • /app/media/analytics \u2014 global analytics dashboard
      "},{"location":"docs/admin/media/curated/","title":"Curated Gallery","text":"

      Curate the public gallery experience with playlists, a shorts feed, and featured content.

      "},{"location":"docs/admin/media/curated/#playlists","title":"Playlists","text":"

      From /app/media/curated:

      • Three types \u2014 admin playlists (managed), user playlists (personal), and public playlists (community)
      • Drag-reorder \u2014 arrange videos within a playlist
      • Featured carousel \u2014 feature playlists on the gallery homepage
      • Dedicated viewer \u2014 full playlist playback page with up-next queue
      "},{"location":"docs/admin/media/curated/#shorts-feed","title":"Shorts Feed","text":"

      TikTok-style vertical video feed for clips under 60 seconds:

      • Automatic classification \u2014 videos under 60 seconds are flagged as shorts
      • Vertical feed \u2014 mobile-optimized swipeable interface at /gallery/shorts
      • Autoplay \u2014 continuous playback as viewers scroll
      "},{"location":"docs/admin/media/curated/#admin-routes","title":"Admin Routes","text":"
      • /app/media/curated \u2014 playlist management
      "},{"location":"docs/admin/media/library/","title":"Library","text":"

      The media library at /app/media/library is where you upload, organize, and publish video and photo content.

      "},{"location":"docs/admin/media/library/#video-upload","title":"Video Upload","text":"
      • Drag-and-drop \u2014 single or batch upload (up to 10GB per file)
      • Supported formats \u2014 MP4, MOV, AVI, MKV, WebM, M4V, FLV
      • Automatic metadata \u2014 FFprobe extracts duration, dimensions, orientation, quality, and audio info
      • Quick actions \u2014 hover a video card for Edit (E), Preview (P), Analytics (A), Schedule (S) keyboard shortcuts
      "},{"location":"docs/admin/media/library/#photo-management","title":"Photo Management","text":"
      • Albums \u2014 organize photos into named collections with cover images
      • Bulk uploads \u2014 drag-and-drop multiple photos with automatic metadata extraction
      • Photo picker \u2014 insert photos into landing pages and email templates via a modal picker
      "},{"location":"docs/admin/media/library/#scheduled-publishing","title":"Scheduled Publishing","text":"
      • Publish/unpublish dates \u2014 set future dates for automatic state changes
      • Timezone support \u2014 11 supported timezones
      • Calendar view \u2014 visualize scheduled items on the Calendar tab
      • BullMQ automation \u2014 jobs fire at scheduled times
      "},{"location":"docs/admin/media/library/#preview-links","title":"Preview Links","text":"

      Generate 24-hour JWT-authenticated preview links for unpublished videos \u2014 useful for stakeholder review before publishing.

      "},{"location":"docs/admin/media/library/#admin-routes","title":"Admin Routes","text":"
      • /app/media/library \u2014 video and photo management
      • /app/media/jobs \u2014 processing job queue monitoring
      "},{"location":"docs/admin/media/moderation/","title":"Moderation","text":"

      Admin tools for reviewing and managing comments across all media content at /app/media/moderation.

      "},{"location":"docs/admin/media/moderation/#moderation-dashboard","title":"Moderation Dashboard","text":"
      • Filter by status \u2014 pending, safe, flagged, hidden
      • Status counts \u2014 summary stats showing total, pending, flagged, hidden, and safe comments
      • Search \u2014 filter by video, date range, or text content
      "},{"location":"docs/admin/media/moderation/#moderation-actions","title":"Moderation Actions","text":"

      For each comment, admins can:

      • Approve \u2014 mark as safe and unhide if previously hidden
      • Hide \u2014 remove from public view with a reason (manual, word filter, spam, or link)
      • Unhide \u2014 restore a previously hidden comment
      • Delete \u2014 permanently remove
      • Add notes \u2014 internal moderation notes (not visible to users)
      "},{"location":"docs/admin/media/moderation/#word-filter","title":"Word Filter","text":"

      A configurable list of words with severity levels:

      Severity Action High Auto-blocks the comment Medium Auto-hides for review Low Flags for moderator attention Custom User-defined severity

      The filter list is cached with a 1-minute TTL and invalidated on changes.

      "},{"location":"docs/admin/media/moderation/#admin-routes","title":"Admin Routes","text":"
      • /app/media/moderation \u2014 comment moderation and word filter management
      "},{"location":"docs/admin/payments/","title":"Payments","text":"

      Accept memberships, product sales, and donations through Stripe. Enable with enablePayments in Settings.

      "},{"location":"docs/admin/payments/#in-this-section","title":"In This Section","text":"
      • Products \u2014 manage merchandise and one-time purchase items with inventory tracking
      • Donations \u2014 donation pages with goals, suggested amounts, and branded thank-you messages
      • Plans \u2014 recurring subscription plans with monthly and yearly billing
      • Settings \u2014 Stripe API key configuration with encrypted storage
      "},{"location":"docs/admin/payments/#how-it-works","title":"How It Works","text":"
      1. Enable payments in Settings or .env (ENABLE_PAYMENTS=true)
      2. Configure Stripe API keys in Settings > Payments (stored encrypted with ENCRYPTION_KEY)
      3. Payment widgets become available on landing pages and MkDocs pages
      "},{"location":"docs/admin/payments/donations/","title":"Donations","text":"

      Create custom branded donation landing pages with independent branding and goals.

      "},{"location":"docs/admin/payments/donations/#donation-pages","title":"Donation Pages","text":"

      From /app/donation-pages:

      • Custom branding \u2014 each page has its own title, description, and cover image
      • Configurable amounts \u2014 set suggested donation amounts per page
      • Thank-you messages \u2014 customizable post-donation confirmation
      • Public slugs \u2014 shareable URL at /donate/:slug
      • Goal tracking \u2014 fundraising goals with progress indicators
      • Multiple campaigns \u2014 run several pages simultaneously with independent tracking
      "},{"location":"docs/admin/payments/donations/#donation-management","title":"Donation Management","text":"

      From /app/donations:

      • View all donations with date, amount, donor info, and status
      • Filter by donation page, date range, or amount
      • Export to CSV for accounting and tax receipts
      "},{"location":"docs/admin/payments/donations/#admin-routes","title":"Admin Routes","text":"
      • /app/donations \u2014 donation management
      • /app/donation-pages \u2014 donation page CRUD
      "},{"location":"docs/admin/payments/plans/","title":"Plans","text":"

      Create and manage recurring subscription plans for campaign supporters at /app/plans.

      "},{"location":"docs/admin/payments/plans/#key-features","title":"Key Features","text":"
      • Tiered plans \u2014 multiple subscription tiers with different pricing and benefits
      • Billing cycles \u2014 monthly and yearly billing options
      • Stripe integration \u2014 subscriptions managed through Stripe for reliable recurring payments
      • Subscriber tracking \u2014 view active subscribers, MRR, and churn metrics
      • Public pricing page \u2014 plans displayed at /pricing
      "},{"location":"docs/admin/payments/plans/#admin-routes","title":"Admin Routes","text":"
      • /app/plans \u2014 subscription plan management
      "},{"location":"docs/admin/payments/products/","title":"Products","text":"

      Manage campaign merchandise and one-time purchase items at /app/products.

      "},{"location":"docs/admin/payments/products/#key-features","title":"Key Features","text":"
      • Product CRUD \u2014 create products with title, description, price, and images
      • Inventory management \u2014 track stock levels and set low-stock alerts
      • Stripe checkout \u2014 seamless payment flow via Stripe
      • Public shop \u2014 products displayed at /shop for public browsing and purchase
      "},{"location":"docs/admin/payments/products/#admin-routes","title":"Admin Routes","text":"
      • /app/products \u2014 product management
      "},{"location":"docs/admin/payments/settings/","title":"Payment Settings","text":"

      Configure Stripe integration from Settings > Payments.

      "},{"location":"docs/admin/payments/settings/#stripe-configuration","title":"Stripe Configuration","text":"
      • Publishable key \u2014 used by the frontend for Stripe Elements and Checkout
      • Secret key \u2014 used by the API for creating charges and managing subscriptions
      • Encrypted storage \u2014 both keys are stored encrypted in the database using the ENCRYPTION_KEY environment variable (AES encryption)
      "},{"location":"docs/admin/payments/settings/#webhook","title":"Webhook","text":"

      Stripe webhooks are automatically configured to handle:

      • Successful payments and subscription renewals
      • Failed payments and subscription cancellations
      • Refunds and disputes
      "},{"location":"docs/admin/payments/settings/#admin-routes","title":"Admin Routes","text":"
      • /app/settings (Payments tab) \u2014 Stripe key configuration
      "},{"location":"docs/admin/services/","title":"Services","text":"

      Manage the platform's infrastructure services, monitoring stack, and third-party integrations.

      "},{"location":"docs/admin/services/#in-this-section","title":"In This Section","text":"
      • Tunnel \u2014 Pangolin tunnel management for public access without port forwarding
      • Monitoring \u2014 Prometheus metrics, Grafana dashboards, and Alertmanager
      • Integrations \u2014 Chat, video conferencing, password manager, whiteboard, Git hosting, automation, and QR codes
      • User Provisioning \u2014 automatic account sync across integrated services
      "},{"location":"docs/admin/services/integrations/","title":"Integrations","text":"

      Changemaker Lite integrates with several self-hosted services. Each runs as a Docker container and can be enabled independently.

      "},{"location":"docs/admin/services/integrations/#team-chat-rocketchat","title":"Team Chat (Rocket.Chat)","text":"

      Self-hosted team chat for volunteer coordination. Enable with enableChat in Settings.

      • Channels & DMs \u2014 organize conversations by topic, team, or campaign
      • Iframe integration \u2014 embedded in the admin dashboard and volunteer portal
      • Floating widget \u2014 minimizable chat FAB on admin pages (toggleable in Settings)
      • SSO-ready \u2014 iframe authentication for seamless login
      • Mobile apps \u2014 native Rocket.Chat apps work with your instance
      • Routes: /app/services/rocketchat, /volunteer/chat
      "},{"location":"docs/admin/services/integrations/#video-conferencing-jitsi-meet","title":"Video Conferencing (Jitsi Meet)","text":"

      Self-hosted video calls integrated with Rocket.Chat via JWT authentication. Enable with enableMeet in Settings.

      • One-click calls \u2014 start a call from any Rocket.Chat channel or DM
      • JWT auth \u2014 participants join automatically with no separate login
      • 4 containers \u2014 jitsi-web, jitsi-prosody (XMPP/JWT), jitsi-jicofo (conference focus), jitsi-jvb (video bridge)
      • Setup: Generate secrets, start containers, configure the Jitsi marketplace app in Rocket.Chat, set token expiration to now + 1hour

      Token Expiration

      Set the Jitsi app's Token Expiration to now + 1hour. A raw number like 120 is interpreted as Unix timestamp 120 (Jan 1970), causing all tokens to appear expired.

      "},{"location":"docs/admin/services/integrations/#password-manager-vaultwarden","title":"Password Manager (Vaultwarden)","text":"

      Bitwarden-compatible password vault for secure team credential sharing.

      • Bitwarden client compatible \u2014 use official browser extensions, desktop apps, and mobile apps
      • Auto-invite \u2014 initial admin user invited on first startup
      • User provisioning \u2014 new platform users can be auto-invited when provisioning is enabled
      • Client setup: Point Bitwarden clients to https://vault.DOMAIN
      "},{"location":"docs/admin/services/integrations/#whiteboard-excalidraw","title":"Whiteboard (Excalidraw)","text":"

      Collaborative whiteboard for brainstorming and campaign planning.

      • Real-time collaboration \u2014 multi-user drawing with WebSocket support
      • Embedded in admin \u2014 full-screen iframe at /app/services/excalidraw
      • Desktop only \u2014 requires a desktop browser for the drawing experience
      • Route: /app/services/excalidraw
      "},{"location":"docs/admin/services/integrations/#git-hosting-gitea","title":"Git Hosting (Gitea)","text":"

      Self-hosted Git repository hosting for campaign code and configuration.

      • Lightweight Git forge \u2014 repositories, issues, pull requests, and wikis
      • User provisioning \u2014 platform users can be auto-provisioned as Gitea accounts
      • Embedded \u2014 accessible at git.DOMAIN or embedded in admin at /app/services/gitea
      "},{"location":"docs/admin/services/integrations/#workflow-automation-n8n","title":"Workflow Automation (n8n)","text":"

      Self-hosted workflow automation for connecting platform events to external services.

      • Visual workflow editor \u2014 drag-and-drop automation builder
      • Webhook triggers \u2014 respond to platform events
      • Embedded \u2014 accessible at n8n.DOMAIN or embedded in admin at /app/services/n8n
      "},{"location":"docs/admin/services/integrations/#qr-code-generator-mini-qr","title":"QR Code Generator (Mini QR)","text":"

      Built-in QR code generation for walk sheets, volunteer invites, and campaign links.

      • Public API \u2014 QR code PNG generation at /api/qr
      • Walk sheet integration \u2014 QR codes embedded in printable walk sheets
      • Volunteer quick join \u2014 QR codes for instant volunteer onboarding
      • Embedded \u2014 admin interface at /app/services/qr
      "},{"location":"docs/admin/services/monitoring/","title":"Monitoring","text":"

      The monitoring stack runs as a Docker Compose profile and provides metrics collection, visualization, and alerting.

      "},{"location":"docs/admin/services/monitoring/#starting-the-stack","title":"Starting the Stack","text":"
      docker compose --profile monitoring up -d\n

      This starts Prometheus, Grafana, Alertmanager, cAdvisor, Node Exporter, and Redis Exporter.

      "},{"location":"docs/admin/services/monitoring/#custom-metrics","title":"Custom Metrics","text":"

      The platform exposes 12 custom cm_* Prometheus metrics:

      • API request rates and latencies
      • BullMQ queue sizes (email, SMS, video scheduling)
      • Active canvass sessions
      • External service health gauges
      "},{"location":"docs/admin/services/monitoring/#grafana-dashboards","title":"Grafana Dashboards","text":"

      Three pre-configured dashboards auto-provisioned from configs/grafana/:

      • API Performance \u2014 request rates, latencies, error rates
      • Application Overview \u2014 queue sizes, active sessions, service health
      • System Health \u2014 container resources, host metrics, Redis stats
      "},{"location":"docs/admin/services/monitoring/#alertmanager","title":"Alertmanager","text":"

      Alert rules in configs/prometheus/alerts.yml cover:

      • API downtime and high error rates
      • Queue backlogs
      • Service connectivity failures
      • Resource utilization thresholds
      "},{"location":"docs/admin/services/monitoring/#admin-routes","title":"Admin Routes","text":"
      • /app/observability \u2014 embedded Grafana dashboards, alert status, and service health (3 tabs)
      "},{"location":"docs/admin/services/monitoring/#direct-access","title":"Direct Access","text":"
      • Grafana: localhost:3001 or grafana.DOMAIN
      • Prometheus: localhost:9090
      • Alertmanager: localhost:9093
      "},{"location":"docs/admin/services/tunnel/","title":"Tunnel (Pangolin)","text":"

      Pangolin provides secure tunneling to expose your self-hosted services to the internet without port forwarding or a static IP.

      "},{"location":"docs/admin/services/tunnel/#setup","title":"Setup","text":"

      From /app/pangolin:

      • Automated setup \u2014 one-command deployment that creates the Pangolin site, updates .env with credentials, and restarts the Newt tunnel container
      • Manual setup \u2014 step-by-step instructions for connecting to an existing Pangolin instance
      "},{"location":"docs/admin/services/tunnel/#resource-management","title":"Resource Management","text":"

      The platform defines 12+ service resources in configs/pangolin/resources.yml:

      • Each resource maps a subdomain (e.g., api.DOMAIN, app.DOMAIN) to an internal service
      • Hourly sync \u2014 nginx cron job pushes resource definitions to Pangolin automatically
      • Status dashboard \u2014 view tunnel connection status and resource health
      "},{"location":"docs/admin/services/tunnel/#newt-container","title":"Newt Container","text":"

      The Newt container runs alongside nginx and tunnels traffic to your services:

      • Configured via PANGOLIN_NEWT_ID and PANGOLIN_NEWT_SECRET environment variables
      • Depends on nginx (all resources route through nginx:80)
      • Auto-restarts on failure
      "},{"location":"docs/admin/services/tunnel/#admin-routes","title":"Admin Routes","text":"
      • /app/pangolin \u2014 tunnel status, setup wizard, and resource management
      "},{"location":"docs/admin/services/user-provisioning/","title":"User Provisioning","text":"

      Automatically create and sync user accounts across integrated services when new platform users are registered. Enable with enableUserProvisioning in Settings.

      "},{"location":"docs/admin/services/user-provisioning/#supported-services","title":"Supported Services","text":"Service Mode Notes Rocket.Chat Always lazy SSO on first access Gitea Eager or lazy Admin API provisioning Vaultwarden Eager or lazy Invite-based (no password management) Listmonk Eager or lazy Subscriber sync"},{"location":"docs/admin/services/user-provisioning/#configuration","title":"Configuration","text":"

      From Settings > User Provisioning:

      • Toggle provisioning per service
      • Choose eager (create immediately on user registration) or lazy (create on first access)
      • View provisioning status per user in the Service Accounts panel on the Users page
      "},{"location":"docs/admin/services/user-provisioning/#bulk-sync","title":"Bulk Sync","text":"

      Trigger a bulk sync from /api/users/provisioning/sync to provision all existing users across enabled services. Useful after enabling a new service.

      "},{"location":"docs/admin/services/user-provisioning/#admin-routes","title":"Admin Routes","text":"
      • /app/users (edit drawer) \u2014 per-user service account status and actions
      • /app/settings (User Provisioning tab) \u2014 per-service toggle and timing
      "},{"location":"docs/admin/web/","title":"Web Content","text":"

      Manage the public-facing web presence \u2014 landing pages, the dynamic homepage, navigation menu, and documentation site.

      "},{"location":"docs/admin/web/#in-this-section","title":"In This Section","text":"
      • Landing Pages \u2014 build campaign microsites with the GrapesJS drag-and-drop editor
      • Homepage \u2014 dynamic public landing page aggregating campaigns, shifts, media, and events
      • Navigation \u2014 customize the public navigation menu with toggles, custom links, and reordering
      • Documentation \u2014 MkDocs site management, page analytics, and comment moderation
      "},{"location":"docs/admin/web/#social-sharing","title":"Social Sharing","text":"

      All public content (campaigns, landing pages, gallery videos) automatically generates Open Graph and Twitter Card meta tags for rich link previews when shared on social media, messaging apps, and search engines. OG responses are cached in Redis for 10 minutes.

      "},{"location":"docs/admin/web/documentation/","title":"Documentation","text":"

      Manage the MkDocs documentation site, track page engagement, and moderate visitor comments.

      "},{"location":"docs/admin/web/documentation/#mkdocs-management","title":"MkDocs Management","text":"

      From Docs (/app/docs):

      • View MkDocs build status and health
      • Browse the documentation file tree
      • Export landing pages to MkDocs as Jinja2 Material theme overrides
      • Configure documentation settings from MkDocs Settings (/app/docs/settings)
      "},{"location":"docs/admin/web/documentation/#documentation-analytics","title":"Documentation Analytics","text":"

      Track how visitors interact with documentation pages using the MkDocs Material theme's custom analytics provider and navigation.tracking.

      • Navigation tracking \u2014 updates the browser URL as users scroll through sections, enabling section-level engagement tracking
      • Custom provider \u2014 integrates with any third-party analytics tool (Plausible, Umami, Google Analytics) via template overrides in docs/overrides/
      "},{"location":"docs/admin/web/documentation/#comments","title":"Comments","text":"

      Visitors can leave comments on documentation pages using a Gitea-backed comment system.

      • Anonymous posting \u2014 visitors can comment without creating an account
      • Gitea-backed \u2014 comments stored as Gitea issues (one issue per page) for version control and searchability
      • Moderation \u2014 admin panel at /app/docs-comments for approving, hiding, or deleting comments
      • OAuth login \u2014 optional Gitea OAuth for authenticated commenting
      • Per-page threads \u2014 each documentation page gets its own comment thread
      "},{"location":"docs/admin/web/documentation/#admin-routes","title":"Admin Routes","text":"
      • /app/docs \u2014 MkDocs management (file tree, config, build triggers)
      • /app/docs/settings \u2014 documentation configuration
      • /app/docs-comments \u2014 moderate documentation comments
      "},{"location":"docs/admin/web/homepage/","title":"Public Homepage","text":"

      A dynamic public landing page that showcases your organization and aggregates content from across the platform.

      "},{"location":"docs/admin/web/homepage/#sections","title":"Sections","text":"

      The homepage assembles its content from enabled modules. Sections are only displayed when their corresponding module is active and has data to show.

      • Hero banner \u2014 organization name, logo, tagline (configurable via Settings), and call-to-action buttons for campaigns and volunteer signups
      • Stats counters \u2014 active campaigns, total emails sent, and volunteer signups (shown only when counts are greater than zero)
      • Featured campaigns \u2014 up to 3 active campaigns, sorted by highlight status then creation date, with email counts and descriptions
      • Upcoming shifts \u2014 up to 3 open shifts with date, time, location, and spots remaining
      • Latest videos \u2014 up to 4 recently published videos from the media library, displayed in a horizontal scroll strip with thumbnails and durations
      • Upcoming events \u2014 up to 3 future events from Gancio with date, location, and tags
      • Recent activity \u2014 a compact activity feed showing the latest platform actions
      "},{"location":"docs/admin/web/homepage/#data-caching","title":"Data & Caching","text":"

      All homepage data is fetched from a single API endpoint (/api/homepage) and cached in Redis for 2 minutes. Individual section queries use Promise.allSettled so that a failure in one module (e.g., Gancio being offline) does not prevent the rest of the page from loading.

      "},{"location":"docs/admin/web/homepage/#configuration","title":"Configuration","text":"
      • Organization name and logo \u2014 set via Settings > Organization
      • Homepage tagline \u2014 set via the homepageTagline field in site settings
      • Module visibility \u2014 controlled by feature flags (enableInfluence, enableMap, enableMediaFeatures, enableEvents, enablePayments, enableLandingPages)
      "},{"location":"docs/admin/web/homepage/#public-routes","title":"Public Routes","text":"
      • /home \u2014 public homepage
      "},{"location":"docs/admin/web/landing-pages/","title":"Landing Pages","text":"

      Build campaign microsites with a drag-and-drop visual editor.

      "},{"location":"docs/admin/web/landing-pages/#how-it-works","title":"How It Works","text":"
      1. Create a new page from the admin panel
      2. Open the GrapesJS visual editor \u2014 drag blocks, edit text, adjust styles
      3. Save and publish \u2014 the page goes live at /p/:slug
      4. Optionally export to MkDocs for inclusion in the documentation site
      "},{"location":"docs/admin/web/landing-pages/#admin-routes","title":"Admin Routes","text":"
      • /app/pages \u2014 list and manage landing pages
      • /app/pages/:id/edit \u2014 full-screen GrapesJS editor
      "},{"location":"docs/admin/web/landing-pages/#public-routes","title":"Public Routes","text":"
      • /p/:slug \u2014 view a published landing page
      "},{"location":"docs/admin/web/navigation/","title":"Navigation Settings","text":"

      Customize the public-facing navigation menu from the admin panel. The navigation bar appears on all public pages, the admin header, the Gancio events page, and the MkDocs documentation site.

      "},{"location":"docs/admin/web/navigation/#key-features","title":"Key Features","text":"
      • Per-item toggle -- enable or disable each navigation item with a switch
      • Custom links -- add external links or internal paths to the navigation
      • Reorder -- move items up and down to arrange them in any order
      • Editable labels and paths -- rename any item or change its destination
      • Feature flag awareness -- builtin items tied to a feature flag (e.g., Campaigns requires enableInfluence) are automatically hidden when that feature is disabled
      • Visitor control -- determine exactly what public visitors can access
      "},{"location":"docs/admin/web/navigation/#builtin-items","title":"Builtin Items","text":"

      The platform ships with 11 builtin navigation items that cover the main public routes:

      Home, Campaigns, Map, Shifts, Events, Gallery, Pricing, Shop, Donate, Website (landing page), and Docs (documentation site).

      Each builtin item has a default icon and path. Some paths use special $ tokens (e.g., $landing, $docs) that are automatically resolved to the correct external URL based on the deployment environment.

      "},{"location":"docs/admin/web/navigation/#custom-links","title":"Custom Links","text":"

      Add any number of custom links via the \"Add Custom Link\" button. Custom links support:

      • Internal paths (e.g., /blog)
      • External URLs (e.g., https://example.com) -- automatically detected and opened in a new tab

      Custom links can be deleted from the navigation; builtin items can only be toggled off.

      "},{"location":"docs/admin/web/navigation/#mobile-handling","title":"Mobile Handling","text":"

      On mobile devices, the navigation collapses into a hamburger menu that opens a full-height drawer. On desktop, the nav bar also supports a collapse mode that hides labels and shows only icons, toggled via a fold/unfold button. The collapse state is persisted in local storage.

      "},{"location":"docs/admin/web/navigation/#admin-routes","title":"Admin Routes","text":"
      • /app/navigation -- navigation editor with per-item toggle, reorder, label editing, and custom link management
      "},{"location":"docs/api/","title":"API Reference","text":"

      Changemaker Lite exposes two REST APIs sharing a single PostgreSQL database.

      Server Framework Port Purpose Main API Express.js 4000 Auth, campaigns, map, shifts, canvassing, pages, email, settings Media API Fastify 4100 Video library, analytics, playlists, reactions, comments

      Both APIs use JWT Bearer authentication and return JSON. All request/response bodies are application/json unless noted otherwise.

      "},{"location":"docs/api/#authentication","title":"Authentication","text":""},{"location":"docs/api/#token-flow","title":"Token Flow","text":"
      sequenceDiagram\n    participant Client\n    participant API\n    participant DB\n\n    Client->>API: POST /api/auth/login {email, password}\n    API->>DB: Verify credentials\n    DB-->>API: User record\n    API-->>Client: {accessToken, refreshToken}\n    Note over Client: Store tokens\n\n    Client->>API: GET /api/campaigns (Authorization: Bearer <accessToken>)\n    API-->>Client: 200 OK\n\n    Note over Client: Access token expires (15 min)\n\n    Client->>API: POST /api/auth/refresh {refreshToken}\n    API->>DB: Rotate token (atomic transaction)\n    DB-->>API: New token pair\n    API-->>Client: {accessToken, refreshToken}
      "},{"location":"docs/api/#headers","title":"Headers","text":"

      All authenticated requests require:

      Authorization: Bearer <accessToken>\n

      The Media API also accepts tokens via query parameter for SSE streams:

      GET /api/public/:id/chat-stream?token=<accessToken>\n
      "},{"location":"docs/api/#roles","title":"Roles","text":"Role Access Level SUPER_ADMIN Full platform access INFLUENCE_ADMIN Campaign and advocacy management MAP_ADMIN Map, locations, shifts, canvassing USER Volunteer portal, public features TEMP Limited access (auto-created on public shift signup)"},{"location":"docs/api/#middleware-reference","title":"Middleware Reference","text":"Middleware Effect authenticate Requires valid JWT. Sets req.user with id, email, role. Returns 401 if missing or invalid. optionalAuth Same as authenticate but continues without user if token is absent. requireRole(...roles) Checks user role against allowed list. Returns 403 if not authorized. requireNonTemp Blocks TEMP users. Returns 403. validate(schema, source) Validates request body/query/params against a Zod schema. Returns 400 on failure."},{"location":"docs/api/#error-responses","title":"Error Responses","text":"

      All errors follow a consistent format:

      {\n  \"error\": {\n    \"message\": \"Human-readable error description\",\n    \"code\": \"ERROR_CODE\",\n    \"statusCode\": 400\n  }\n}\n
      Status Code Meaning 400 VALIDATION_ERROR Request body/query failed schema validation 401 UNAUTHORIZED Missing or invalid access token 403 FORBIDDEN Valid token but insufficient role 404 NOT_FOUND Resource does not exist 429 RATE_LIMITED Too many requests (see Rate Limits) 500 INTERNAL_ERROR Unexpected server error

      Enumeration Prevention

      Auth endpoints (/login, /register, /forgot-password) return generic success messages to prevent user enumeration. A 401 from /api/auth/me does not reveal whether the user exists.

      "},{"location":"docs/api/#rate-limits","title":"Rate Limits","text":"

      Rate limits are Redis-backed and keyed by IP address.

      Endpoint Group Window Max Requests Redis Prefix Auth (login, register, refresh) 15 min 10 rl:auth: Email sending 1 hour 30 rl:email: Response submission 1 hour 10 rl:response: Shift signup 1 hour 10 rl:shift-signup: Canvass visits 1 min 30 rl:canvass-visit: Canvass bulk visits 1 min 5 rl:canvass-visit-bulk: GPS tracking 1 min 6 rl:gps-tracking: Canvass geocode 1 min 10 rl:canvass-geocode: Observability 1 min 20 rl:observability: Health/metrics 1 min 30 rl:health-metrics: Global (all other) Configurable Configurable rl:global:

      When rate-limited, the API returns:

      {\n  \"error\": {\n    \"message\": \"Too many requests, please try again later\",\n    \"code\": \"RATE_LIMITED\",\n    \"statusCode\": 429\n  }\n}\n
      "},{"location":"docs/api/#main-api-express-port-4000","title":"Main API (Express \u2014 Port 4000)","text":""},{"location":"docs/api/#health-metrics","title":"Health & Metrics","text":"Method Path Auth Description GET /api/health Health check \u2014 PostgreSQL + Redis ping GET /api/metrics Prometheus metrics (text/plain) Health response
      {\n  \"status\": \"healthy\",\n  \"checks\": {\n    \"database\": \"ok\",\n    \"redis\": \"ok\"\n  }\n}\n
      "},{"location":"docs/api/#auth","title":"Auth","text":"

      Prefix: /api/auth

      Method Path Auth Rate Limited Description POST /api/auth/login Email + password login POST /api/auth/register Create account (always USER role) POST /api/auth/verify-email Verify email with token POST /api/auth/resend-verification Resend verification email POST /api/auth/forgot-password Send password reset email POST /api/auth/reset-password Set new password with reset token POST /api/auth/refresh Rotate refresh token \u2192 new token pair POST /api/auth/logout Invalidate refresh token GET /api/auth/me Current user profile Login request & response

      Request:

      {\n  \"email\": \"admin@example.com\",\n  \"password\": \"SecurePass123!\"\n}\n
      Response:
      {\n  \"accessToken\": \"eyJhbG...\",\n  \"refreshToken\": \"eyJhbG...\",\n  \"user\": {\n    \"id\": \"uuid\",\n    \"email\": \"admin@example.com\",\n    \"name\": \"Admin\",\n    \"role\": \"SUPER_ADMIN\"\n  }\n}\n

      Password Policy

      Passwords must be at least 12 characters with at least one uppercase letter, one lowercase letter, and one digit.

      "},{"location":"docs/api/#users","title":"Users","text":"

      Prefix: /api/users \u00b7 Auth: All routes require authentication

      Method Path Role Description GET /api/users Admin Paginated user list with search, role, and status filters GET /api/users/:id Admin or self Single user profile POST /api/users Admin Create user PUT /api/users/:id Admin or self Update user (non-admins cannot change role/status) POST /api/users/:id/approve Admin Approve pending user; sends approval email POST /api/users/:id/reject Admin Reject pending user DELETE /api/users/:id Admin Delete user

      Query parameters for GET /api/users:

      Param Type Description page number Page number (default 1) limit number Items per page (default 20) search string Search by name or email role string Filter by role status string Filter by status"},{"location":"docs/api/#dashboard","title":"Dashboard","text":"

      Prefix: /api/dashboard \u00b7 Auth: Admin roles required

      Method Path Role Description GET /api/dashboard/summary Any admin Platform-wide counts (users, campaigns, locations, shifts) GET /api/dashboard/system SUPER_ADMIN Hardware + OS info (CPU, memory, disk) GET /api/dashboard/containers SUPER_ADMIN Docker container statuses GET /api/dashboard/weather Any admin Current weather at map center coordinates GET /api/dashboard/api-metrics SUPER_ADMIN Prometheus API performance metrics GET /api/dashboard/time-series SUPER_ADMIN Prometheus time-series data GET /api/dashboard/container-resources SUPER_ADMIN cAdvisor CPU/memory/network per container

      Query parameters for GET /api/dashboard/time-series:

      Param Type Description metrics string Comma-separated metric keys (whitelist-validated) range string Time range (e.g., 1h, 24h, 7d) step string Sample interval (e.g., 5m, 1h)"},{"location":"docs/api/#campaigns","title":"Campaigns","text":""},{"location":"docs/api/#admin-crud","title":"Admin CRUD","text":"

      Prefix: /api/campaigns \u00b7 Auth: Admin roles

      Method Path Description GET /api/campaigns Paginated campaign list GET /api/campaigns/:id Single campaign detail POST /api/campaigns Create campaign PUT /api/campaigns/:id Update campaign DELETE /api/campaigns/:id Delete campaign"},{"location":"docs/api/#public","title":"Public","text":"Method Path Auth Description GET /api/campaigns/public List all active campaigns GET /api/campaigns/:slug/details Campaign detail by slug (ACTIVE only)"},{"location":"docs/api/#user-submissions","title":"User Submissions","text":"

      Auth: Authenticated, non-TEMP users

      Method Path Description POST /api/campaigns/user/submit Submit campaign for moderation (5/hour limit) GET /api/campaigns/user/my-campaigns List own submitted campaigns PUT /api/campaigns/user/:id Edit own pending campaign"},{"location":"docs/api/#moderation","title":"Moderation","text":"

      Auth: Admin roles

      Method Path Description GET /api/campaigns/moderation/queue Campaigns pending moderation GET /api/campaigns/moderation/stats Moderation queue statistics PATCH /api/campaigns/moderation/:id Approve or reject campaign"},{"location":"docs/api/#campaign-emails","title":"Campaign Emails","text":"Method Path Auth Description POST /api/campaigns/:slug/send-email Send advocacy email to representatives (rate limited: 30/hour) POST /api/campaigns/:slug/track-mailto Track mailto link click GET /api/campaigns/:id/emails Admin Paginated emails for campaign GET /api/campaigns/:id/email-stats Admin Email statistics"},{"location":"docs/api/#responses","title":"Responses","text":"

      Prefix: /api/campaigns (public) and /api/responses (admin + actions)

      "},{"location":"docs/api/#public_1","title":"Public","text":"Method Path Auth Description GET /api/campaigns/:slug/responses List approved public responses GET /api/campaigns/:slug/response-stats Response statistics POST /api/campaigns/:slug/responses Submit response (rate limited: 10/hour) POST /api/responses/:id/upvote Optional Upvote a response DELETE /api/responses/:id/upvote Optional Remove upvote GET /api/responses/:id/verify/:token Verify response via email link"},{"location":"docs/api/#admin","title":"Admin","text":"

      Auth: Admin roles

      Method Path Description GET /api/responses All responses with filters PATCH /api/responses/:id/status Approve or reject response POST /api/responses/:id/resend-verification Resend verification email DELETE /api/responses/:id Delete response"},{"location":"docs/api/#representatives","title":"Representatives","text":"

      Prefix: /api/representatives

      Method Path Auth Description GET /api/representatives/by-postal/:postalCode Lookup representatives by postal code (cache-first) GET /api/representatives/test-connection Represent API health check GET /api/representatives/cache-stats Admin Cache statistics GET /api/representatives Admin Paginated cached representatives GET /api/representatives/:id Admin Single cached representative DELETE /api/representatives/by-postal/:postalCode Admin Clear cache for postal code DELETE /api/representatives/:id Admin Delete cached representative

      Query parameters for postal code lookup:

      Param Type Description refresh boolean Force API call, bypass cache"},{"location":"docs/api/#email-queue","title":"Email Queue","text":"

      Prefix: /api/email-queue \u00b7 Auth: Admin roles

      Method Path Description GET /api/email-queue/stats BullMQ queue statistics (waiting, active, completed, failed) POST /api/email-queue/pause Pause email processing POST /api/email-queue/resume Resume email processing POST /api/email-queue/clean Clean completed jobs"},{"location":"docs/api/#locations","title":"Locations","text":"

      Prefix: /api/map/locations

      "},{"location":"docs/api/#public_2","title":"Public","text":"Method Path Description GET /api/map/locations/public All geocoded locations for map (no PII); optional ?bounds="},{"location":"docs/api/#admin_1","title":"Admin","text":"

      Auth: SUPER_ADMIN or MAP_ADMIN

      Method Path Description GET /api/map/locations Paginated locations with filters GET /api/map/locations/stats Location statistics GET /api/map/locations/all All geocoded locations for admin map GET /api/map/locations/export-csv CSV export GET /api/map/locations/:id Single location GET /api/map/locations/:id/history Edit history POST /api/map/locations Create location PUT /api/map/locations/:id Update location DELETE /api/map/locations/:id Delete location POST /api/map/locations/bulk-delete Bulk delete POST /api/map/locations/geocode Geocode single address POST /api/map/locations/geocode-missing Batch geocode all ungeocoded POST /api/map/locations/reverse-geocode Reverse geocode lat/lng to address POST /api/map/locations/import-csv Import from CSV (10 MB limit) POST /api/map/locations/import-bulk Bulk NAR or standard CSV import (100 MB limit)"},{"location":"docs/api/#bulk-geocode","title":"Bulk Geocode","text":"

      Prefix: /api/map/locations/bulk-geocode \u00b7 Auth: Map admins

      Method Path Description POST /api/map/locations/bulk-geocode Start BullMQ bulk geocoding job GET /api/map/locations/bulk-geocode/:jobId Poll job status GET /api/map/locations/bulk-geocode/stats Queue statistics"},{"location":"docs/api/#nar-import","title":"NAR Import","text":"

      Prefix: /api/map/nar-import \u00b7 Auth: Map admins

      Method Path Description GET /api/map/nar-import/datasets Available NAR datasets by province POST /api/map/nar-import Start province import (fire-and-forget) GET /api/map/nar-import/status/:importId Poll import progress NAR Import body
      {\n  \"provinceCode\": \"24\",\n  \"filterType\": \"city\",\n  \"filterCity\": \"Edmonton\",\n  \"residentialOnly\": true,\n  \"deduplicateRadius\": 10,\n  \"batchSize\": 500\n}\n
      "},{"location":"docs/api/#area-import","title":"Area Import","text":"

      Prefix: /api/map/area-import \u00b7 Auth: Map admins

      Method Path Description POST /api/map/area-import/preview Preview bounds + estimated record counts POST /api/map/area-import Start area import (fire-and-forget) GET /api/map/area-import/status/:importId Poll import progress"},{"location":"docs/api/#cuts-polygons","title":"Cuts (Polygons)","text":"

      Prefix: /api/map/cuts

      Method Path Auth Description GET /api/map/cuts/public All public cuts as GeoJSON GET /api/map/cuts Map admin Paginated cuts list GET /api/map/cuts/:id Map admin Single cut POST /api/map/cuts Map admin Create cut (polygon GeoJSON) PUT /api/map/cuts/:id Map admin Update cut DELETE /api/map/cuts/:id Map admin Delete cut GET /api/map/cuts/:id/locations Map admin All locations within cut polygon GET /api/map/cuts/:id/statistics Map admin Support level breakdown GET /api/map/cuts/export-geojson Map admin All cuts as GeoJSON FeatureCollection GET /api/map/cuts/:id/export-geojson Map admin Single cut as GeoJSON Feature POST /api/map/cuts/import-geojson Map admin Import cuts from GeoJSON file"},{"location":"docs/api/#shifts","title":"Shifts","text":"

      Prefix: /api/map/shifts

      "},{"location":"docs/api/#public_3","title":"Public","text":"Method Path Description GET /api/map/shifts/public List upcoming public shifts POST /api/map/shifts/public/:id/signup Public signup (creates TEMP user if needed; rate limited: 10/hour)"},{"location":"docs/api/#volunteer","title":"Volunteer","text":"

      Auth: Any authenticated user

      Method Path Description GET /api/map/shifts/volunteer/upcoming Upcoming shifts with signup status GET /api/map/shifts/volunteer/my-signups Own confirmed signups POST /api/map/shifts/volunteer/:id/signup Sign up for shift DELETE /api/map/shifts/volunteer/:id/signup Cancel signup"},{"location":"docs/api/#admin_2","title":"Admin","text":"

      Auth: Map admins

      Method Path Description GET /api/map/shifts Paginated shifts with filters GET /api/map/shifts/stats Statistics GET /api/map/shifts/calendar Calendar data (?startDate=&endDate=) GET /api/map/shifts/:id Single shift with signups POST /api/map/shifts Create shift PUT /api/map/shifts/:id Update shift DELETE /api/map/shifts/:id Delete shift POST /api/map/shifts/:id/signups Admin-add volunteer DELETE /api/map/shifts/:id/signups/:signupId Remove volunteer POST /api/map/shifts/:id/email-details Email details to all volunteers"},{"location":"docs/api/#shift-series","title":"Shift Series","text":"

      Auth: Map admins

      Method Path Description POST /api/map/shifts/series Create recurring shift series GET /api/map/shifts/series/:id Get series PUT /api/map/shifts/series/:id Update series DELETE /api/map/shifts/series/:id Delete series"},{"location":"docs/api/#canvassing","title":"Canvassing","text":"

      Prefix: /api/map/canvass

      "},{"location":"docs/api/#volunteer_1","title":"Volunteer","text":"

      Auth: Any authenticated user

      Method Path Description GET /api/map/canvass/my/assignments Shift assignments GET /api/map/canvass/my/stats Personal canvass statistics GET /api/map/canvass/my/visits Visit history GET /api/map/canvass/my/session Active canvass session POST /api/map/canvass/sessions Start canvass session POST /api/map/canvass/sessions/:id/end End session GET /api/map/canvass/cuts/:cutId/locations Locations in cut with visit annotations GET /api/map/canvass/cuts/:cutId/route Walking route algorithm for cut GET /api/map/canvass/locations All locations with visit annotations PUT /api/map/canvass/locations/:id Edit address (role-gated fields) POST /api/map/canvass/locations Create location POST /api/map/canvass/reverse-geocode Reverse geocode lat/lng POST /api/map/canvass/geocode-search Geocode address for map (rate limited: 10/min) POST /api/map/canvass/visits Record door knock (rate limited: 30/min) POST /api/map/canvass/visits/bulk Record visit for all unvisited units (rate limited: 5/min)"},{"location":"docs/api/#admin_3","title":"Admin","text":"

      Auth: SUPER_ADMIN or MAP_ADMIN

      Method Path Description GET /api/map/canvass/stats Platform-wide canvass statistics GET /api/map/canvass/stats/cuts/:cutId Statistics for specific cut GET /api/map/canvass/activity Recent activity feed GET /api/map/canvass/volunteers All volunteers with canvass activity GET /api/map/canvass/volunteers/:userId Individual volunteer statistics GET /api/map/canvass/visits All visits with filters"},{"location":"docs/api/#gps-tracking","title":"GPS Tracking","text":"

      Prefix: /api/map/tracking

      "},{"location":"docs/api/#volunteer_2","title":"Volunteer","text":"

      Auth: Any authenticated user

      Method Path Description POST /api/map/tracking/sessions Start GPS tracking session POST /api/map/tracking/sessions/:id/end End tracking session POST /api/map/tracking/sessions/:id/points Submit GPS point batch (rate limited: 6/min) POST /api/map/tracking/sessions/:id/link-canvass Link to canvass session GET /api/map/tracking/my/session Active tracking session GET /api/map/tracking/my/sessions Own historical sessions GET /api/map/tracking/my/sessions/:id/route Full route for own session"},{"location":"docs/api/#admin_4","title":"Admin","text":"

      Auth: Map admins

      Method Path Description GET /api/map/tracking/live Live volunteer positions + trails GET /api/map/tracking/sessions All historical tracking sessions GET /api/map/tracking/sessions/:id/route Full route for any session"},{"location":"docs/api/#map-settings","title":"Map Settings","text":"

      Prefix: /api/map/settings

      Method Path Auth Description GET /api/map/settings Public map settings (center, zoom, walk sheet config) PUT /api/map/settings Map admin Update map settings"},{"location":"docs/api/#geocoding","title":"Geocoding","text":"

      Prefix: /api/map/geocoding \u00b7 Auth: Map admins

      Method Path Description GET /api/map/geocoding/search Geocode address search (?q=&limit=1-10)"},{"location":"docs/api/#landing-pages","title":"Landing Pages","text":"

      Prefix: /api/pages and /api/page-blocks

      "},{"location":"docs/api/#public_4","title":"Public","text":"Method Path Auth Description GET /api/pages/:slug/view Get published page by slug"},{"location":"docs/api/#admin_5","title":"Admin","text":"

      Auth: Admin roles

      Method Path Description GET /api/pages Paginated landing pages GET /api/pages/:id Single page POST /api/pages Create page PUT /api/pages/:id Update page DELETE /api/pages/:id Delete page POST /api/pages/sync Sync MkDocs overrides from filesystem POST /api/pages/validate Validate and repair MkDocs exports"},{"location":"docs/api/#block-library","title":"Block Library","text":"

      Auth: Admin roles

      Method Path Description GET /api/page-blocks List blocks GET /api/page-blocks/:id Single block POST /api/page-blocks Create block PUT /api/page-blocks/:id Update block DELETE /api/page-blocks/:id Delete block"},{"location":"docs/api/#email-templates","title":"Email Templates","text":"

      Prefix: /api/email-templates \u00b7 Auth: Admin roles (seed/cache require SUPER_ADMIN)

      Method Path Description GET /api/email-templates List templates GET /api/email-templates/:id Single template POST /api/email-templates Create template PUT /api/email-templates/:id Update template DELETE /api/email-templates/:id Delete template GET /api/email-templates/:id/versions Version history GET /api/email-templates/:id/versions/:versionNumber Specific version POST /api/email-templates/:id/rollback Rollback to prior version POST /api/email-templates/validate Validate Handlebars syntax POST /api/email-templates/:id/test Send test email (rate limited: 10/15min) GET /api/email-templates/:id/test-logs Test send logs POST /api/email-templates/seed Seed templates from filesystem POST /api/email-templates/clear-cache Clear template cache"},{"location":"docs/api/#qr-codes","title":"QR Codes","text":"Method Path Auth Description GET /api/qr Generate QR code PNG (?text=&size=50-500)

      Cached for 1 hour. Returns image/png.

      "},{"location":"docs/api/#site-settings","title":"Site Settings","text":"

      Prefix: /api/settings

      Method Path Auth Description GET /api/settings Public site settings (SMTP credentials stripped) GET /api/settings/admin SUPER_ADMIN Full settings including SMTP credentials PUT /api/settings SUPER_ADMIN Update settings POST /api/settings/email/test-connection SUPER_ADMIN Test SMTP connection POST /api/settings/email/test-send SUPER_ADMIN Send test email"},{"location":"docs/api/#listmonk-newsletter-sync","title":"Listmonk (Newsletter Sync)","text":"

      Prefix: /api/listmonk \u00b7 Auth: SUPER_ADMIN

      Method Path Description GET /api/listmonk Sync status + connection check GET /api/listmonk/stats Subscriber counts from Listmonk POST /api/listmonk/test-connection Health check POST /api/listmonk/sync/participants Sync campaign participants POST /api/listmonk/sync/locations Sync locations POST /api/listmonk/sync/users Sync users POST /api/listmonk/sync/all Run all sync operations POST /api/listmonk/reinitialize Reinitialize Listmonk lists GET /api/listmonk/proxy-url Proxy port + JWT for iframe"},{"location":"docs/api/#documentation-management","title":"Documentation Management","text":"

      Prefix: /api/docs \u00b7 Auth: Authenticated, non-TEMP (write operations require SUPER_ADMIN)

      Method Path Description GET /api/docs/status MkDocs + Code Server availability GET /api/docs/config Port numbers for iframe URLs GET /api/docs/mkdocs-config Read raw mkdocs.yml PUT /api/docs/mkdocs-config Write mkdocs.yml POST /api/docs/build Trigger MkDocs build POST /api/docs/upload Upload asset (20 MB, whitelisted extensions) GET /api/docs/files File tree (?force=true bypasses cache) POST /api/docs/files/rename Rename or move file GET /api/docs/files/* Read file content PUT /api/docs/files/* Write file content POST /api/docs/files/* Create file or folder DELETE /api/docs/files/* Delete file or empty folder"},{"location":"docs/api/#services","title":"Services","text":"

      Prefix: /api/services \u00b7 Auth: SUPER_ADMIN

      Method Path Description GET /api/services/status Health check all managed services (NocoDB, n8n, Gitea, MailHog, Mini QR, Excalidraw, Homepage) GET /api/services/config Port numbers + subdomain info"},{"location":"docs/api/#pangolin-tunnel-management","title":"Pangolin (Tunnel Management)","text":"

      Prefix: /api/pangolin \u00b7 Auth: SUPER_ADMIN

      Method Path Description GET /api/pangolin/status Tunnel health + connection info GET /api/pangolin/config Current env configuration GET /api/pangolin/newt-status Newt container status POST /api/pangolin/newt-restart Restart Newt container GET /api/pangolin/sites List Pangolin sites GET /api/pangolin/exit-nodes Available exit nodes GET /api/pangolin/resource-definitions Resource definitions from YAML GET /api/pangolin/resources List resources POST /api/pangolin/setup Create site + all resources (rate limited: \u2157min) POST /api/pangolin/sync Sync resources (create missing, update changed) PUT /api/pangolin/resource/:id Update resource DELETE /api/pangolin/resource/:id Delete resource GET /api/pangolin/resource/:id/clients Connected clients GET /api/pangolin/certificate/:domainId/:domain Certificate info POST /api/pangolin/certificate/:certId Update certificate"},{"location":"docs/api/#observability","title":"Observability","text":"

      Prefix: /api/observability \u00b7 Auth: SUPER_ADMIN \u00b7 Rate limited: 20/min

      Method Path Description GET /api/observability/status Check 7 monitoring services GET /api/observability/metrics-summary Key metrics from Prometheus GET /api/observability/alerts Active alerts from Alertmanager"},{"location":"docs/api/#payments","title":"Payments","text":"

      Prefix: /api/payments

      "},{"location":"docs/api/#public_5","title":"Public","text":"Method Path Auth Description GET /api/payments/config Stripe publishable key + donation settings GET /api/payments/plans Active subscription plans GET /api/payments/products Active products (?type=) POST /api/payments/subscribe Create subscription checkout POST /api/payments/purchase Optional Product checkout (guest or logged-in) POST /api/payments/donate Donation checkout GET /api/payments/my-subscription Current subscription POST /api/payments/my-subscription/cancel Cancel subscription POST /api/payments/webhook Stripe webhook (raw body)"},{"location":"docs/api/#admin_6","title":"Admin","text":"

      Auth: SUPER_ADMIN

      Method Path Description GET /api/payments/admin/settings Payment settings (secrets masked) PUT /api/payments/admin/settings Update payment settings POST /api/payments/admin/settings/test-connection Test Stripe connection GET /api/payments/admin/dashboard Subscription + donation statistics GET /api/payments/admin/plans All subscription plans POST /api/payments/admin/plans Create plan PUT /api/payments/admin/plans/:id Update plan DELETE /api/payments/admin/plans/:id Delete plan POST /api/payments/admin/plans/:id/sync-stripe Sync plan to Stripe GET /api/payments/admin/subscriptions All subscriptions with filters POST /api/payments/admin/subscriptions/:id/cancel Cancel subscription GET /api/payments/admin/products All products POST /api/payments/admin/products Create product PUT /api/payments/admin/products/:id Update product DELETE /api/payments/admin/products/:id Delete product POST /api/payments/admin/products/:id/sync-stripe Sync product to Stripe GET /api/payments/admin/orders List orders POST /api/payments/admin/orders/:id/refund Refund order GET /api/payments/admin/donations List donations GET /api/payments/admin/export CSV export of completed orders"},{"location":"docs/api/#media-api-fastify-port-4100","title":"Media API (Fastify \u2014 Port 4100)","text":"

      The Media API is a separate Fastify server sharing the same PostgreSQL database. It handles all video-related functionality.

      "},{"location":"docs/api/#health","title":"Health","text":"Method Path Auth Description GET /health Media API health check"},{"location":"docs/api/#videos-admin","title":"Videos (Admin)","text":"

      Prefix: /api/videos \u00b7 Auth: Admin roles

      "},{"location":"docs/api/#crud-publishing","title":"CRUD & Publishing","text":"Method Path Description GET /api/videos List videos (?limit=&offset=&search=&orientation=&producers=&isShort=) GET /api/videos/producers Distinct producer list GET /api/videos/health Video count health check GET /api/videos/:id Single video detail PATCH /api/videos/:id Update metadata (title, producer, tags, quality, etc.) POST /api/videos/:id/publish Publish to category POST /api/videos/:id/unpublish Unpublish POST /api/videos/bulk-publish Bulk publish POST /api/videos/bulk-unpublish Bulk unpublish POST /api/videos/:id/lock Lock published video POST /api/videos/:id/unlock Unlock video POST /api/videos/:id/generate-thumbnail Generate thumbnail via FFmpeg POST /api/videos/bulk-generate-thumbnails Bulk thumbnail generation"},{"location":"docs/api/#upload","title":"Upload","text":"Method Path Description POST /api/videos/upload Single video upload (multipart, 10 GB limit, streams to disk) POST /api/videos/upload/batch Batch upload (returns 207 multi-status)"},{"location":"docs/api/#actions","title":"Actions","text":"Method Path Description POST /api/videos/:id/duplicate Duplicate video record POST /api/videos/:id/replace Replace video file, keep metadata GET /api/videos/:id/analytics Detailed analytics (?startDate=&endDate=) POST /api/videos/:id/reset-analytics Reset all analytics GET /api/videos/:id/preview-link Generate 24-hour JWT preview link GET /api/videos/analytics/top Top videos (?metric=views|watchTime&limit=) GET /api/videos/analytics/overview Global analytics overview"},{"location":"docs/api/#scheduling","title":"Scheduling","text":"Method Path Description POST /api/videos/:id/schedule-publish Schedule future publish ({publishAt, timezone?}) POST /api/videos/:id/schedule-unpublish Schedule future unpublish DELETE /api/videos/:id/schedule/:action Cancel scheduled operation GET /api/videos/schedules/upcoming Upcoming scheduled operations GET /api/videos/:id/schedule-history Schedule history for video GET /api/videos/schedules/stats Schedule queue statistics POST /api/videos/schedules/pause Pause schedule queue POST /api/videos/schedules/resume Resume schedule queue POST /api/videos/schedules/cleanup Clean old completed jobs"},{"location":"docs/api/#video-fetch","title":"Video Fetch","text":"Method Path Description POST /api/videos/fetch Submit fetch job ({urls: string[]}, 1\u201320 URLs) GET /api/videos/fetch/jobs List recent fetch jobs GET /api/videos/fetch/jobs/:jobId Job detail + log GET /api/videos/fetch/jobs/:jobId/log SSE log stream (Redis pub/sub) DELETE /api/videos/fetch/jobs/:jobId Cancel fetch job"},{"location":"docs/api/#streaming-public","title":"Streaming (Public)","text":"

      Prefix: /api/videos

      Method Path Auth Description GET /api/videos/stream/health Streaming health check GET /api/videos/:id/stream Optional HTTP range-supporting video stream GET /api/videos/:id/thumbnail Optional Serve thumbnail image GET /api/videos/:id/metadata Public video metadata for embedding

      Note

      Admins can stream unpublished videos by providing a valid JWT.

      "},{"location":"docs/api/#public-gallery","title":"Public Gallery","text":"

      Prefix: /api/public

      Method Path Auth Description GET /api/public Optional Published videos (?limit=&offset=&search=&sort=recent|popular|oldest&category=) GET /api/public/categories Optional Categories with video counts GET /api/public/producers Optional Published producers GET /api/public/:id Optional Single published video GET /api/public/:id/thumbnail Optional Published thumbnail GET /api/public/:id/stream Optional Published video stream"},{"location":"docs/api/#tracking","title":"Tracking","text":"

      Prefix: /api/track \u00b7 Auth: None required

      Method Path Description GET /api/track/health Tracking health check POST /api/track/view Record video view (returns {viewId}) POST /api/track/event Record play/pause/seek/complete event POST /api/track/heartbeat Update watch time (10s interval, sendBeacon) POST /api/track/batch Batch up to 50 tracking events Tracking is GDPR-compliant

      IP addresses are hashed with a daily-rotating salt. Raw IPs are never stored. Tracking data is retained for 90 days.

      "},{"location":"docs/api/#reactions","title":"Reactions","text":"

      Prefix: /api/reactions

      Method Path Auth Description GET /api/reactions/config Available reaction types + emoji mappings GET /api/reactions List reactions (?mediaId=&userId=&limit=) GET /api/reactions/:mediaId/chat Reactions in chat timeline format POST /api/reactions Add reaction (30s cooldown per type)

      Available types: like, love, laugh, wow, sad, angry

      "},{"location":"docs/api/#comments-chat","title":"Comments & Chat","text":""},{"location":"docs/api/#public-comments","title":"Public Comments","text":"Method Path Auth Description GET /api/public/:id/comments List comments (?limit=&offset=) POST /api/public/:id/comments Optional Create comment (word-filtered; rate limited: 5/min) GET /api/public/:id/chat-stream SSE stream for real-time chat (30s keepalive)"},{"location":"docs/api/#comment-admin","title":"Comment Admin","text":"

      Prefix: /api/media/admin/comments \u00b7 Auth: Admin roles

      Method Path Description GET /api/media/admin/comments/stats Counts by status GET /api/media/admin/comments All comments with filters PATCH /api/media/admin/comments/:id/approve Approve comment PATCH /api/media/admin/comments/:id/hide Hide comment PATCH /api/media/admin/comments/:id/unhide Unhide comment PUT /api/media/admin/comments/:id/notes Update moderation notes DELETE /api/media/admin/comments/:id Delete comment"},{"location":"docs/api/#word-filters","title":"Word Filters","text":"

      Prefix: /api/media/admin/word-filters \u00b7 Auth: Admin roles

      Method Path Description GET /api/media/admin/word-filters List filter entries grouped by level POST /api/media/admin/word-filters Add word ({word, level: low|medium|high|custom}) DELETE /api/media/admin/word-filters/:id Remove word"},{"location":"docs/api/#chat-threads-notifications","title":"Chat Threads & Notifications","text":"

      Auth: Authenticated

      Method Path Description GET /api/media/chat/threads Videos with user's comments + unread counts POST /api/media/chat/threads/:mediaId/read Mark thread as read GET /api/media/notifications/stream Per-user SSE notification stream (?token=)"},{"location":"docs/api/#shorts","title":"Shorts","text":"Method Path Auth Description GET /api/shorts Optional Shorts feed (?sort=recent|popular|random) POST /api/shorts/scan Admin Auto-classify short videos by duration"},{"location":"docs/api/#upvotes","title":"Upvotes","text":"Method Path Auth Description POST /api/public/:id/upvote Toggle upvote (session-based via X-Session-ID header) GET /api/public/:id/upvote-status Check upvote status for current session"},{"location":"docs/api/#playlists","title":"Playlists","text":""},{"location":"docs/api/#public_6","title":"Public","text":"

      Prefix: /api/playlists

      Method Path Auth Description GET /api/playlists/featured Optional Featured playlists GET /api/playlists/popular Optional Popular public playlists (?search=) GET /api/playlists/share/:token Optional Playlist by share token GET /api/playlists/:id Optional Playlist detail (public, owner, or share token) POST /api/playlists/:id/view Optional Record playlist view"},{"location":"docs/api/#user-playlists","title":"User Playlists","text":"

      Auth: Authenticated

      Method Path Description GET /api/playlists/my Own playlists POST /api/playlists Create playlist PUT /api/playlists/:id Update playlist (ownership check) DELETE /api/playlists/:id Delete playlist POST /api/playlists/:id/videos Add video ({mediaId}) DELETE /api/playlists/:id/videos/:mediaId Remove video PUT /api/playlists/:id/videos/reorder Reorder videos POST /api/playlists/:id/share Generate share token DELETE /api/playlists/:id/share Revoke share token"},{"location":"docs/api/#playlist-admin","title":"Playlist Admin","text":"

      Prefix: /api/media/playlists \u00b7 Auth: Admin roles

      Method Path Description GET /api/media/playlists All playlists GET /api/media/playlists/featured Featured playlists with admin info POST /api/media/playlists/:id/feature Feature a playlist DELETE /api/media/playlists/:id/feature Unfeature a playlist PUT /api/media/playlists/featured/reorder Reorder featured playlists PUT /api/media/playlists/:id Admin update any playlist POST /api/media/playlists/:id/duplicate Duplicate playlist DELETE /api/media/playlists/:id Admin delete any playlist"},{"location":"docs/api/#user-profile","title":"User Profile","text":"

      Prefix: /api/media/me \u00b7 Auth: Authenticated

      Method Path Description GET /api/media/me/stats User stats + 30-day activity + achievements GET /api/media/me/watch-history Paginated watch history POST /api/media/me/stats/recalculate Recompute stats from raw data GET /api/media/me/settings Privacy settings PUT /api/media/me/settings Update privacy settings PUT /api/media/me/profile Update display name PUT /api/media/me/password Change password"},{"location":"docs/api/#route-summary","title":"Route Summary","text":"API Module Endpoint Count Express Auth 9 Users 7 Dashboard 7 Campaigns (CRUD + public + user + moderation + emails) 16 Responses 10 Email Queue 4 Representatives 7 Locations (CRUD + geocode + import) 21 Cuts 11 Shifts (CRUD + series) 19 Canvassing 20 GPS Tracking 10 Map Settings + Geocoding 3 Pages + Blocks 12 Email Templates 13 QR Codes 1 Site Settings 5 Listmonk 9 Docs Management 11 Services 2 Pangolin 16 Observability 3 Payments (public + admin) 29 Health + Metrics 3 Express Total ~248 Fastify Videos (CRUD + upload + actions + schedule + fetch) 39 Streaming 4 Public Gallery 6 Tracking 5 Reactions 4 Comments + Chat 13 Shorts + Upvotes 4 Playlists (public + user + admin) 18 User Profile 7 Health 1 Fastify Total ~101 Grand Total ~349"},{"location":"docs/architecture/","title":"Architecture","text":"

      Changemaker Lite uses a dual-API architecture with a shared PostgreSQL database.

      Under Construction

      Detailed architecture documentation is being written. Check back soon.

      "},{"location":"docs/architecture/#system-overview","title":"System Overview","text":"
      Browser \u2500\u2500\u25ba Nginx (reverse proxy) \u2500\u2500\u252c\u2500\u2500\u25ba Express API (port 4000) \u2500\u2500\u25ba PostgreSQL\n                                    \u251c\u2500\u2500\u25ba Fastify Media API (port 4100) \u2500\u2500\u2518\n                                    \u251c\u2500\u2500\u25ba React Admin GUI (port 3000)\n                                    \u2514\u2500\u2500\u25ba MkDocs / Other Services\n
      "},{"location":"docs/architecture/#key-components","title":"Key Components","text":"Component Technology Role Main API Express.js + Prisma Auth, campaigns, map, shifts, pages Media API Fastify + Prisma Video library, analytics, uploads Admin GUI React + Ant Design Single-page admin application Database PostgreSQL 16 Shared by both APIs (30+ tables) Cache Redis Rate limiting, job queues, geocoding Proxy Nginx Subdomain routing, security headers"},{"location":"docs/architecture/#authentication-flow","title":"Authentication Flow","text":"
      • JWT access tokens (15 min) + refresh tokens (7 days)
      • Refresh token rotation with atomic database transaction
      • Role-based access control (5 roles)
      • Rate limiting on auth endpoints (10/min per IP)
      "},{"location":"docs/deployment/","title":"Deployment","text":"

      This guide covers how to take Changemaker Lite from a local development setup to a publicly accessible production deployment. The main decision is how to expose your services to the internet.

      "},{"location":"docs/deployment/#architecture-overview","title":"Architecture Overview","text":"

      Regardless of which exposure method you choose, the internal architecture is the same:

      Internet \u2192 [Your exposure method] \u2192 Nginx (port 80) \u2192 Backend Services\n

      Nginx handles all subdomain routing internally. Every service is accessed through nginx on port 80, which proxies to the correct container based on the Host header.

      Subdomain Service Container Port app.DOMAIN Admin GUI + public pages 3000 api.DOMAIN Express API 4000 media.DOMAIN Fastify Media API 4100 DOMAIN (root) MkDocs documentation site 4004 db.DOMAIN NocoDB 8091 docs.DOMAIN MkDocs live preview 4003 code.DOMAIN Code Server 8888 git.DOMAIN Gitea 3030 n8n.DOMAIN Workflow automation 5678 home.DOMAIN Homepage dashboard 3010 listmonk.DOMAIN Newsletter manager 9001 mail.DOMAIN MailHog (dev email) 8025 qr.DOMAIN Mini QR generator 8089 draw.DOMAIN Excalidraw whiteboard 8090 vault.DOMAIN Vaultwarden password manager 8445 chat.DOMAIN Rocket.Chat team chat \u2014 events.DOMAIN Gancio event management 8092 grafana.DOMAIN Monitoring dashboards 3005"},{"location":"docs/deployment/#exposure-methods","title":"Exposure Methods","text":""},{"location":"docs/deployment/#pangolin","title":"Option 1: Pangolin + Newt Tunnel (Recommended)","text":"

      Admin GUI: Tunnel Management Page

      The admin dashboard includes a dedicated Tunnel Management page at Admin \u2192 Settings \u2192 Tunnel. This page provides:

      • Live status of the Pangolin connection and Newt container health
      • Step-by-step setup instructions if credentials aren't configured yet
      • Full resource table listing every service, its domain, and target \u2014 useful as a reference when creating resources in the Pangolin dashboard
      • API-based site creation as an alternative to the Pangolin dashboard UI
      • Restart Newt button for quick container restarts without the terminal

      If you're unsure about any step above, the Tunnel page walks you through the same process interactively.

      Pangolin is a self-hosted tunnel server. The Newt client container runs alongside your stack and establishes an outbound connection to your Pangolin server, which then routes public traffic back through the tunnel. No port forwarding or static IP required.

      Advantages:

      • No port forwarding needed on your router/firewall
      • Works behind CGNAT, double NAT, or restrictive networks
      • SSL/TLS handled by the Pangolin server
      • Self-hosted \u2014 you control the tunnel infrastructure
      • Built-in access control (optional per-resource authentication)

      Requirements:

      • A Pangolin server (self-hosted on a VPS with a public IP)
      • A domain with DNS pointing to the Pangolin server
      • Pangolin API key and organization ID
      "},{"location":"docs/deployment/#step-1-configure-pangolin-credentials","title":"Step 1: Configure Pangolin Credentials","text":"

      If you used config.sh, you may have already set these. Otherwise, add to your .env:

      PANGOLIN_API_URL=https://api.your-pangolin-server.org/v1\nPANGOLIN_API_KEY=your_api_key_here\nPANGOLIN_ORG_ID=your_org_id\n
      "},{"location":"docs/deployment/#step-2-create-a-site-in-pangolin","title":"Step 2: Create a Site in Pangolin","text":"

      Log in to your Pangolin dashboard and create a new site:

      1. Navigate to Sites \u2192 Create New Site
      2. Choose type: Newt
      3. Enter a name (e.g., changemaker-yourdomain.org)
      4. Choose a subnet (e.g., 100.90.128.3/24)
      5. Select an exit node (if applicable)
      6. Click Create Site
      7. Copy the credentials \u2014 you'll need the Site ID, Newt ID, and Newt Secret

      Save the credentials

      The Newt Secret is only shown once during site creation. Copy it immediately.

      "},{"location":"docs/deployment/#step-3-update-env-with-site-credentials","title":"Step 3: Update .env with Site Credentials","text":"
      PANGOLIN_SITE_ID=your_site_id\nPANGOLIN_ENDPOINT=https://your-pangolin-server.org\nPANGOLIN_NEWT_ID=your_newt_id\nPANGOLIN_NEWT_SECRET=your_newt_secret\n
      "},{"location":"docs/deployment/#step-4-start-the-newt-container","title":"Step 4: Start the Newt Container","text":"
      docker compose up -d newt\n

      The Newt container connects to nginx (its only dependency) and establishes the tunnel:

      # From docker-compose.yml\nnewt:\n  image: fosrl/newt\n  container_name: newt-changemaker\n  restart: unless-stopped\n  environment:\n    - PANGOLIN_ENDPOINT=${PANGOLIN_ENDPOINT}\n    - NEWT_ID=${PANGOLIN_NEWT_ID}\n    - NEWT_SECRET=${PANGOLIN_NEWT_SECRET}\n  depends_on:\n    - nginx\n

      Verify the connection:

      docker compose logs newt --tail 20\n

      You should see a successful connection message.

      "},{"location":"docs/deployment/#step-5-create-public-http-resources","title":"Step 5: Create Public HTTP Resources","text":"

      In the Pangolin dashboard, create an HTTP resource for each service you want exposed. All resources point to nginx:80 \u2014 nginx handles the routing internally.

      Required resources (minimum for a working deployment):

      Resource Name Domain Target Auth Admin GUI app.yourdomain.org nginx:80 Not Protected API Server api.yourdomain.org nginx:80 Not Protected Public Site yourdomain.org nginx:80 Not Protected

      Optional resources (add as needed):

      Resource Name Domain Target Media API media.yourdomain.org nginx:80 NocoDB db.yourdomain.org nginx:80 Documentation docs.yourdomain.org nginx:80 Code Server code.yourdomain.org nginx:80 Gitea git.yourdomain.org nginx:80 Grafana grafana.yourdomain.org nginx:80

      Set resources to Not Protected

      By default, Pangolin may enable authentication on new resources. This causes 302 redirects to the Pangolin login page instead of reaching your services. Set each resource to Not Protected (public access) unless you intentionally want Pangolin SSO in front of it.

      "},{"location":"docs/deployment/#step-6-update-cors-for-production","title":"Step 6: Update CORS for Production","text":"

      Add your production domain to CORS_ORIGINS in .env:

      CORS_ORIGINS=https://app.yourdomain.org,http://localhost:3000,http://localhost\n

      Then restart the API:

      docker compose restart api\n
      "},{"location":"docs/deployment/#step-7-verify","title":"Step 7: Verify","text":"
      # Should return JSON (not a 302 redirect)\ncurl https://api.yourdomain.org/api/health\n\n# Admin GUI should load\ncurl -I https://app.yourdomain.org\n
      "},{"location":"docs/deployment/#cloudflare","title":"Option 2: Cloudflare Tunnel","text":"

      Cloudflare Tunnel (cloudflared) provides a similar zero-trust tunnel approach using Cloudflare's network. No port forwarding needed, and you get Cloudflare's CDN and DDoS protection.

      Advantages:

      • Free tier available
      • Built-in CDN and DDoS protection
      • No port forwarding needed
      • Managed SSL certificates

      Disadvantages:

      • Proprietary service (not self-hosted)
      • Cloudflare sees all traffic (no end-to-end encryption to your origin)
      • Subject to Cloudflare's Terms of Service
      "},{"location":"docs/deployment/#setup","title":"Setup","text":"
      1. Create a Cloudflare Tunnel in the Zero Trust dashboard

      2. Add a cloudflared service to your docker-compose.yml:

        cloudflared:\n  image: cloudflare/cloudflared:latest\n  container_name: cloudflared-changemaker\n  restart: unless-stopped\n  command: tunnel run\n  environment:\n    - TUNNEL_TOKEN=${CLOUDFLARE_TUNNEL_TOKEN}\n  depends_on:\n    - nginx\n  networks:\n    - changemaker-lite\n
      3. Add your tunnel token to .env:

        CLOUDFLARE_TUNNEL_TOKEN=your_tunnel_token_here\n
      4. Configure public hostnames in the Cloudflare dashboard, all pointing to http://nginx:80:

        Hostname Service app.yourdomain.org http://nginx:80 api.yourdomain.org http://nginx:80 yourdomain.org http://nginx:80 (add more as needed) http://nginx:80
      5. Start the tunnel:

        docker compose up -d cloudflared\n

      Note

      The cloudflared service is not included in the default docker-compose.yml. Add it manually if you choose this method. The Newt service can be removed or left stopped.

      "},{"location":"docs/deployment/#direct","title":"Option 3: Direct DNS + Reverse Proxy","text":"

      If your server has a public IP address (e.g., a VPS or dedicated server), you can point DNS directly to it and use nginx with SSL certificates.

      Advantages:

      • No tunnel overhead or third-party dependency
      • Full control over the network path
      • Lowest latency

      Disadvantages:

      • Requires a public IP and open ports (80, 443)
      • You manage SSL certificates yourself
      • Server IP is exposed
      "},{"location":"docs/deployment/#setup_1","title":"Setup","text":"
      1. Point DNS for your domain and all subdomains to your server's IP:

        A     yourdomain.org        \u2192 YOUR_SERVER_IP\nA     *.yourdomain.org      \u2192 YOUR_SERVER_IP\n

        Or use individual A records for each subdomain if your DNS provider doesn't support wildcards.

      2. Open ports 80 and 443 on your server's firewall.

      3. Install Certbot (or another ACME client) for SSL certificates:

        # Ubuntu/Debian\nsudo apt install certbot\n\n# Get a wildcard certificate with DNS challenge\nsudo certbot certonly --manual --preferred-challenges dns \\\n  -d yourdomain.org -d '*.yourdomain.org'\n

        Alternatively, use the Certbot Docker image or a Let's Encrypt companion container.

      4. Update nginx to listen on 443 with your certificates. Add an SSL server block to nginx/conf.d/ssl.conf:

        server {\n    listen 443 ssl;\n    server_name app.yourdomain.org;\n\n    ssl_certificate /etc/nginx/ssl/fullchain.pem;\n    ssl_certificate_key /etc/nginx/ssl/privkey.pem;\n\n    location / {\n        proxy_pass http://changemaker-v2-admin:3000;\n        proxy_set_header Host $host;\n        proxy_set_header X-Real-IP $remote_addr;\n        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n        proxy_set_header X-Forwarded-Proto $scheme;\n        proxy_set_header Upgrade $http_upgrade;\n        proxy_set_header Connection \"upgrade\";\n    }\n}\n\n# Repeat for api.yourdomain.org, media.yourdomain.org, etc.\n# Or use a single server block with $host matching\n
      5. Mount certificates into the nginx container via docker-compose.yml:

        nginx:\n  volumes:\n    - /etc/letsencrypt/live/yourdomain.org:/etc/nginx/ssl:ro\n
      6. Set up auto-renewal with a cron job or systemd timer:

        0 3 * * * certbot renew --quiet && docker compose restart nginx\n

      Traefik alternative

      If you prefer automatic SSL and don't want to manage nginx SSL config manually, consider replacing nginx with Traefik. Traefik can auto-discover Docker containers and provision Let's Encrypt certificates automatically. This would require adapting the container labels and removing the nginx service.

      "},{"location":"docs/deployment/#tailscale","title":"Option 4: Tailscale / WireGuard (Private Access)","text":"

      For deployments that should only be accessible to specific people (not the general public), a mesh VPN like Tailscale or plain WireGuard gives you private networking without exposing anything to the internet.

      Use cases:

      • Internal team deployments
      • Development/staging servers
      • Access from mobile devices without public exposure
      "},{"location":"docs/deployment/#tailscale-setup","title":"Tailscale Setup","text":"
      1. Install Tailscale on your server and client devices
      2. Access services via Tailscale IP (e.g., http://100.x.x.x:3000)
      3. Optionally use Tailscale Funnel to selectively expose specific services publicly
      "},{"location":"docs/deployment/#wireguard-setup","title":"WireGuard Setup","text":"
      1. Set up a WireGuard server on your host
      2. Connect client devices via WireGuard config
      3. Access services via the WireGuard interface IP

      Note

      With private access methods, you may not need subdomain routing at all. Access services directly by port: http://server-ip:3000 (admin), http://server-ip:4000 (API), etc.

      "},{"location":"docs/deployment/#production-checklist","title":"Production Checklist","text":"

      Before going live, verify each item:

      "},{"location":"docs/deployment/#security","title":"Security","text":"
      • All placeholder passwords changed (grep -c \"REQUIRED_STRONG\" .env should return 0)
      • NODE_ENV=production set in .env
      • ENCRYPTION_KEY set and differs from JWT secrets
      • EMAIL_TEST_MODE=false (unless you want MailHog in production)
      • CORS_ORIGINS includes your production domain
      • Admin password changed after first login
      • Redis password set (REDIS_PASSWORD)
      "},{"location":"docs/deployment/#networking","title":"Networking","text":"
      • DNS records configured for your domain and subdomains
      • SSL/TLS working (tunnel handles this, or manual certs)
      • All Pangolin resources set to \"Not Protected\" (if using Pangolin)
      • curl https://api.yourdomain.org/api/health returns JSON
      "},{"location":"docs/deployment/#services","title":"Services","text":"
      • Core services running: docker compose ps shows api, admin, v2-postgres, redis, nginx healthy
      • Database migrated: docker compose exec api npx prisma migrate deploy
      • Database seeded: docker compose exec api npx prisma db seed
      • Admin GUI accessible at https://app.yourdomain.org
      "},{"location":"docs/deployment/#backups","title":"Backups","text":"
      • Backup script tested: ./scripts/backup.sh
      • Backup cron job configured (see Backups below)
      • Restore procedure tested at least once
      "},{"location":"docs/deployment/#monitoring-optional","title":"Monitoring (Optional)","text":"
      • Monitoring stack started: docker compose --profile monitoring up -d
      • Grafana accessible and dashboards loading
      • Alert rules configured in Alertmanager
      "},{"location":"docs/deployment/#backups_1","title":"Backups","text":"

      The included backup script dumps PostgreSQL databases, archives uploads, and optionally uploads to S3.

      "},{"location":"docs/deployment/#running-a-backup","title":"Running a Backup","text":"
      ./scripts/backup.sh\n

      This creates a timestamped directory under ./backups/ containing:

      • changemaker_v2.sql.gz \u2014 Main PostgreSQL dump (compressed)
      • listmonk.sql.gz \u2014 Listmonk database dump (if running)
      • uploads.tar.gz \u2014 Media uploads archive
      • manifest.json \u2014 Backup metadata
      "},{"location":"docs/deployment/#options","title":"Options","text":"
      # Upload to S3 (requires AWS CLI + S3_BUCKET env var)\n./scripts/backup.sh --s3\n\n# Custom retention (delete local backups older than N days)\n./scripts/backup.sh --retention 14\n
      "},{"location":"docs/deployment/#automated-backups","title":"Automated Backups","text":"

      Add a cron job for daily backups:

      # Edit crontab\ncrontab -e\n\n# Add daily backup at 3 AM\n0 3 * * * /path/to/changemaker.lite/scripts/backup.sh >> /var/log/changemaker-backup.log 2>&1\n\n# With S3 upload\n0 3 * * * /path/to/changemaker.lite/scripts/backup.sh --s3 >> /var/log/changemaker-backup.log 2>&1\n
      "},{"location":"docs/deployment/#restore","title":"Restore","text":"
      # Restore main database\ngunzip -c backups/changemaker-v2-backup-TIMESTAMP/changemaker_v2.sql.gz | \\\n  docker compose exec -T v2-postgres psql -U changemaker changemaker_v2\n\n# Restore Listmonk database\ngunzip -c backups/changemaker-v2-backup-TIMESTAMP/listmonk.sql.gz | \\\n  docker compose exec -T listmonk-db psql -U listmonk listmonk\n\n# Restore uploads\ntar xzf backups/changemaker-v2-backup-TIMESTAMP/uploads.tar.gz -C ./\n
      "},{"location":"docs/deployment/#monitoring","title":"Monitoring","text":"

      The monitoring stack runs behind a Docker Compose profile and is not started by default.

      "},{"location":"docs/deployment/#starting-the-monitoring-stack","title":"Starting the Monitoring Stack","text":"
      docker compose --profile monitoring up -d\n

      This starts:

      Service Port Purpose Prometheus 9090 Metrics collection and queries Grafana 3005 Dashboards and visualization Alertmanager 9093 Alert routing and notifications cAdvisor 8086 Container resource metrics Node Exporter 9100 Host system metrics Redis Exporter 9121 Redis metrics Gotify 8889 Push notifications"},{"location":"docs/deployment/#pre-configured-dashboards","title":"Pre-configured Dashboards","text":"

      Grafana includes 3 auto-provisioned dashboards:

      1. API Overview \u2014 HTTP request rates, latency, error rates, active sessions
      2. Infrastructure \u2014 Container CPU/memory, PostgreSQL connections, Redis memory
      3. Campaign Activity \u2014 Email queue size, campaign sends, response submissions
      "},{"location":"docs/deployment/#custom-metrics","title":"Custom Metrics","text":"

      The API exposes 12 custom Prometheus metrics with the cm_ prefix:

      • cm_api_uptime_seconds \u2014 API uptime
      • cm_email_queue_size \u2014 BullMQ pending emails
      • cm_active_canvass_sessions \u2014 Active canvassing sessions
      • cm_locations_total \u2014 Total locations in database
      • And more \u2014 see api/src/utils/metrics.ts
      "},{"location":"docs/deployment/#alert-rules","title":"Alert Rules","text":"

      Pre-configured alerts in configs/prometheus/alerts.yml:

      • API down for more than 5 minutes
      • High error rate (>5% of requests returning 5xx)
      • Database connection failures
      • Redis connection failures
      • Email queue backlog
      • Disk space warnings
      "},{"location":"docs/deployment/#upgrading","title":"Upgrading","text":""},{"location":"docs/deployment/#pulling-updates","title":"Pulling Updates","text":"
      # Pull latest code\ngit pull origin v2\n\n# Rebuild and restart containers\ndocker compose build api admin\ndocker compose up -d api admin\n\n# Run any new migrations\ndocker compose exec api npx prisma migrate deploy\n
      "},{"location":"docs/deployment/#database-migrations","title":"Database Migrations","text":"

      Always run migrations after pulling updates:

      docker compose exec api npx prisma migrate deploy\n

      Back up first

      Always run ./scripts/backup.sh before applying migrations in production. Migrations may alter table structures and are not easily reversible.

      "},{"location":"docs/deployment/#troubleshooting-production-issues","title":"Troubleshooting Production Issues","text":""},{"location":"docs/deployment/#pangolin-302-redirects-instead-of-content","title":"Pangolin: 302 Redirects Instead of Content","text":"

      Symptom: API returns 302 redirects to the Pangolin authentication page.

      Fix: In the Pangolin dashboard, edit each resource and set Authentication to Not Protected.

      "},{"location":"docs/deployment/#cors-errors","title":"CORS Errors","text":"

      Symptom: Browser console shows CORS errors when accessing the production domain.

      Fix: Add your production app. subdomain to CORS_ORIGINS in .env, then docker compose restart api.

      "},{"location":"docs/deployment/#newt-wont-connect","title":"Newt Won't Connect","text":"

      Check in order:

      1. Credentials: Verify PANGOLIN_NEWT_ID and PANGOLIN_NEWT_SECRET in .env
      2. Endpoint: Confirm PANGOLIN_ENDPOINT matches your Pangolin server URL
      3. Logs: docker compose logs newt --tail 50
      4. Nginx running: Newt depends on nginx \u2014 docker compose ps nginx
      5. Network: Ensure outbound HTTPS is not blocked by your firewall
      "},{"location":"docs/deployment/#services-unreachable-via-tunnel","title":"Services Unreachable via Tunnel","text":"
      1. Verify nginx is running: docker compose ps nginx
      2. Test locally first: curl http://localhost:4000/api/health
      3. Check nginx logs: docker compose logs nginx --tail 50
      4. Verify DNS: dig app.yourdomain.org should point to your Pangolin server

      See Troubleshooting for more common issues.

      "},{"location":"docs/getting-started/","title":"Getting Started","text":"

      This guide walks you through installing Changemaker Lite, running your first deployment, and logging into the admin dashboard.

      "},{"location":"docs/getting-started/#prerequisites","title":"Prerequisites","text":"
      • Docker 24+ and Docker Compose v2
      • OpenSSL (for secret generation)
      • A Linux server (Ubuntu 22.04+ recommended) or macOS for development
      • At least 2 GB RAM and 10 GB disk space
      • A domain name (optional, but recommended for production)
      "},{"location":"docs/getting-started/#installation","title":"Installation","text":""},{"location":"docs/getting-started/#1-clone-the-repository","title":"1. Clone the Repository","text":"
      git clone https://gitea.bnkops.com/admin/changemaker.lite\ncd changemaker.lite\ngit checkout v2\n
      "},{"location":"docs/getting-started/#2-run-the-configuration-wizard","title":"2. Run the Configuration Wizard","text":"

      The fastest way to get a working .env file is the interactive configuration wizard:

      bash config.sh\n

      The wizard walks you through each step:

      Step What it does Prerequisites check Verifies Docker, Docker Compose, and OpenSSL are installed Domain Sets your root domain and updates all subdomain references (nginx, Gitea, n8n, MkDocs, etc.) Admin credentials Prompts for the initial super-admin email and password (enforces 12+ chars, uppercase, lowercase, digit) Secret generation Auto-generates 16 unique secrets \u2014 JWT keys, encryption key, database passwords, Redis password, API tokens SMTP Optionally configures production SMTP (defaults to MailHog for development) Feature flags Enable/disable Media Manager and Listmonk newsletter sync Pangolin tunnel Optionally configures tunnel credentials for public access CORS Auto-sets allowed origins based on your domain Homepage Generates configs/homepage/services.yaml with all service links for your domain Permissions Creates required directories and sets container-friendly permissions

      After completion you'll have a fully populated .env with no placeholder passwords remaining.

      Already have a .env?

      If a .env file exists, the wizard offers to back it up before creating a fresh one, or update values in place.

      What the wizard looks like
        \u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2557  \u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2557   \u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\n \u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255d\u2588\u2588\u2551  \u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2557  \u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255d \u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255d\n \u2588\u2588\u2551     \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2554\u2588\u2588\u2557 \u2588\u2588\u2551\u2588\u2588\u2551  \u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2557\n \u2588\u2588\u2551     \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2551\u2588\u2588\u2551\u255a\u2588\u2588\u2557\u2588\u2588\u2551\u2588\u2588\u2551   \u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u255d\n \u255a\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2551  \u2588\u2588\u2551\u2588\u2588\u2551  \u2588\u2588\u2551\u2588\u2588\u2551 \u255a\u2588\u2588\u2588\u2588\u2551\u255a\u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255d\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\n  \u255a\u2550\u2550\u2550\u2550\u2550\u255d\u255a\u2550\u255d  \u255a\u2550\u255d\u255a\u2550\u255d  \u255a\u2550\u255d\u255a\u2550\u255d  \u255a\u2550\u2550\u2550\u255d \u255a\u2550\u2550\u2550\u2550\u2550\u255d \u255a\u2550\u2550\u2550\u2550\u2550\u2550\u255d\n\n \u2588\u2588\u2588\u2557   \u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2557  \u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2557\n \u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2551 \u2588\u2588\u2554\u255d\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255d\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\n \u2588\u2588\u2554\u2588\u2588\u2588\u2588\u2554\u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2588\u2588\u2588\u2554\u255d \u2588\u2588\u2588\u2588\u2588\u2557  \u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255d\n \u2588\u2588\u2551\u255a\u2588\u2588\u2554\u255d\u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2551\u2588\u2588\u2554\u2550\u2588\u2588\u2557 \u2588\u2588\u2554\u2550\u2550\u255d  \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\n \u2588\u2588\u2551 \u255a\u2550\u255d \u2588\u2588\u2551\u2588\u2588\u2551  \u2588\u2588\u2551\u2588\u2588\u2551  \u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2551  \u2588\u2588\u2551\n \u255a\u2550\u255d     \u255a\u2550\u255d\u255a\u2550\u255d  \u255a\u2550\u255d\u255a\u2550\u255d  \u255a\u2550\u255d\u255a\u2550\u2550\u2550\u2550\u2550\u2550\u255d\u255a\u2550\u255d  \u255a\u2550\u255d\n                     V2 Configuration Wizard\n\n[INFO] This wizard will create your .env file, generate secure secrets,\n[INFO] and prepare your system to run the full Changemaker Lite stack.\n
      "},{"location":"docs/getting-started/#3-manual-setup-alternative","title":"3. Manual Setup (Alternative)","text":"

      If you prefer to configure things by hand:

      cp .env.example .env\n

      Then edit .env and at minimum set these values:

      V2_POSTGRES_PASSWORD=<strong password>\nREDIS_PASSWORD=<strong password>\nJWT_ACCESS_SECRET=<openssl rand -hex 32>\nJWT_REFRESH_SECRET=<openssl rand -hex 32>\nENCRYPTION_KEY=<openssl rand -hex 32>\nINITIAL_ADMIN_PASSWORD=<12+ chars, mixed case + digit>\n

      See Environment Variables for every available option.

      "},{"location":"docs/getting-started/#4-start-services","title":"4. Start Services","text":"
      # Start core services\ndocker compose up -d v2-postgres redis api admin\n\n# Run database migrations and seed the initial admin account\ndocker compose exec api npx prisma migrate deploy\ndocker compose exec api npx prisma db seed\n
      "},{"location":"docs/getting-started/#5-log-in","title":"5. Log In","text":"

      Open http://localhost:3000 and sign in with the admin email and password you configured.

      Change your password

      If you used the wizard's generated password, change it immediately from the admin dashboard.

      "},{"location":"docs/getting-started/#optional-services","title":"Optional Services","text":"

      Once the core is running, add more services as needed:

      # Reverse proxy (required for subdomain routing)\ndocker compose up -d nginx\n\n# Video library\ndocker compose up -d media-api\n\n# Newsletters\ndocker compose up -d listmonk-app\n\n# Service dashboard\ndocker compose up -d homepage\n\n# All services at once\ndocker compose up -d\n\n# Monitoring stack (Prometheus, Grafana, Alertmanager)\ndocker compose --profile monitoring up -d\n
      "},{"location":"docs/getting-started/#next-steps","title":"Next Steps","text":"
      • Environment Variables \u2014 complete .env reference
      • First Steps \u2014 create your first campaign and add locations
      • Features at a Glance \u2014 visual overview of every module
      • Admin Guide \u2014 full administration reference
      • Deployment \u2014 production setup with SSL and tunneling
      "},{"location":"docs/getting-started/environment-variables/","title":"Environment Variables","text":"

      Changemaker Lite uses a single .env file at the project root to configure all services. Copy the example file to get started:

      cp .env.example .env\n

      Security Essentials

      • Change every REQUIRED_STRONG_PASSWORD_CHANGE_THIS value before starting services
      • Generate secrets with openssl rand -hex 32 (or -hex 16 where noted)
      • Never commit .env to version control
      • Use unique values for each secret \u2014 do not reuse JWT secrets as encryption keys
      "},{"location":"docs/getting-started/environment-variables/#quick-reference","title":"Quick Reference","text":"

      Variables are grouped by service. Each table marks whether a variable is required for a basic deployment or optional (has a sensible default or only needed for specific features).

      Symbol Meaning Must be set before first run Has a working default; change for production Feature flag \u2014 opt-in"},{"location":"docs/getting-started/environment-variables/#general","title":"General","text":"Variable Default Description NODE_ENV development Set to production for production deployments. Controls logging, error detail, and security checks. DOMAIN cmlite.org Root domain. Used for nginx subdomain routing (app.DOMAIN, api.DOMAIN, etc.). The root domain serves the MkDocs documentation site; all application routes live under app.DOMAIN. USER_ID 1000 UID for container file ownership. Match your host user's UID (id -u). GROUP_ID 1000 GID for container file ownership. Match your host user's GID (id -g). DOCKER_GROUP_ID 984 GID of the docker group on the host. Needed for containers that access the Docker socket. Find with getent group docker."},{"location":"docs/getting-started/environment-variables/#postgresql-main-database","title":"PostgreSQL (Main Database)","text":"

      The primary database for both the Express API and the Fastify Media API (shared).

      Variable Default Description V2_POSTGRES_USER changemaker Database username. V2_POSTGRES_PASSWORD \u2014 Must change. Database password. V2_POSTGRES_DB changemaker_v2 Database name. V2_POSTGRES_PORT 5433 Host port mapping. The container listens on 5432 internally.

      Connection string

      The DATABASE_URL is constructed automatically inside Docker. If running locally, set:

      DATABASE_URL=postgresql://changemaker:YOUR_PASSWORD@localhost:5433/changemaker_v2\n

      "},{"location":"docs/getting-started/environment-variables/#jwt-authentication","title":"JWT Authentication","text":"Variable Default Description JWT_ACCESS_SECRET \u2014 Secret for signing access tokens. Generate with openssl rand -hex 32. JWT_REFRESH_SECRET \u2014 Secret for signing refresh tokens. Must differ from the access secret. JWT_ACCESS_EXPIRY 15m Access token lifetime. Short-lived by design. JWT_REFRESH_EXPIRY 7d Refresh token lifetime. Tokens are rotated atomically on each refresh."},{"location":"docs/getting-started/environment-variables/#encryption-key","title":"Encryption Key","text":"Variable Default Description ENCRYPTION_KEY \u2014 AES key for encrypting secrets stored in the database (SMTP passwords, API keys, etc.). Generate with openssl rand -hex 32. Must not reuse a JWT secret. Required in production (NODE_ENV=production)."},{"location":"docs/getting-started/environment-variables/#initial-admin-account","title":"Initial Admin Account","text":"

      These credentials create the first super-admin user during database seeding (npx prisma db seed).

      Variable Default Description INITIAL_ADMIN_EMAIL admin@cmlite.org Email address for the initial admin. INITIAL_ADMIN_PASSWORD \u2014 Must change. Must be 12+ characters with uppercase, lowercase, and a digit. Change this password after first login."},{"location":"docs/getting-started/environment-variables/#api-server","title":"API Server","text":"Variable Default Description API_PORT 4000 Host port for the Express API. API_URL http://localhost:4000 Public URL of the API. Used for generating links in emails and QR codes. CORS_ORIGINS http://localhost:3000,http://localhost Comma-separated list of allowed CORS origins. Add your production domain (e.g., https://app.yourdomain.org) for production.

      Production CORS

      If you deploy behind a tunnel (Pangolin, Cloudflare) and API requests fail with CORS errors, add your production app. subdomain here:

      CORS_ORIGINS=https://app.betteredmonton.org,http://localhost:3000,http://localhost\n
      Then restart the API: docker compose restart api

      "},{"location":"docs/getting-started/environment-variables/#admin-gui","title":"Admin GUI","text":"Variable Default Description ADMIN_PORT 3000 Host port for the React admin dashboard. ADMIN_URL http://localhost:3000 Public URL of the admin GUI."},{"location":"docs/getting-started/environment-variables/#nginx-reverse-proxy","title":"Nginx Reverse Proxy","text":"Variable Default Description NGINX_HTTP_PORT 80 HTTP port. All subdomains route through nginx. NGINX_HTTPS_PORT 443 HTTPS port. SSL is typically handled by the tunnel provider (Pangolin/Cloudflare)."},{"location":"docs/getting-started/environment-variables/#redis","title":"Redis","text":"

      Shared by rate limiting, BullMQ job queues, geocoding cache, and session data.

      Variable Default Description REDIS_PASSWORD \u2014 Must change. Redis requires authentication. REDIS_URL redis://:${REDIS_PASSWORD}@redis-changemaker:6379 Full connection URL. Uses the password variable automatically."},{"location":"docs/getting-started/environment-variables/#payments-stripe","title":"Payments (Stripe)","text":"Variable Default Description ENABLE_PAYMENTS false Set to true to enable the payments feature (memberships, products, donations). Stripe API keys are stored encrypted in the database via the admin settings page."},{"location":"docs/getting-started/environment-variables/#email-smtp","title":"Email / SMTP","text":"Variable Default Description SMTP_HOST mailhog-changemaker SMTP server. Default points to the MailHog dev container. SMTP_PORT 1025 SMTP port. 1025 for MailHog, 587 for most production SMTP. SMTP_USER (empty) SMTP username. Not needed for MailHog. SMTP_PASS (empty) SMTP password. SMTP_FROM noreply@cmlite.org \"From\" address on outgoing emails. SMTP_FROM_NAME Changemaker Lite Display name for the \"From\" header. EMAIL_TEST_MODE true When true, all emails go to MailHog instead of real SMTP. Set to false in production. TEST_EMAIL_RECIPIENT admin@cmlite.org Catch-all recipient when test mode is on.

      Development email

      With EMAIL_TEST_MODE=true, all outgoing email is captured in MailHog at http://localhost:8025. No real emails are sent.

      "},{"location":"docs/getting-started/environment-variables/#listmonk-newsletters","title":"Listmonk (Newsletters)","text":"

      Listmonk handles newsletter/marketing campaigns. Sync with the main platform is opt-in.

      Variable Default Description LISTMONK_PORT 9001 Listmonk web UI port. LISTMONK_DB_PORT 5434 Listmonk's own PostgreSQL port (separate from the main DB). Uses 5434 to avoid conflict with the main PostgreSQL (5432 internal / 5433 host). LISTMONK_DB_USER listmonk Listmonk database user. LISTMONK_DB_PASSWORD \u2014 Listmonk database password. LISTMONK_DB_NAME listmonk Listmonk database name. LISTMONK_WEB_ADMIN_USER admin Login for the Listmonk web dashboard. LISTMONK_WEB_ADMIN_PASSWORD \u2014 Password for the Listmonk web dashboard. LISTMONK_API_USER v2-api API user for programmatic access (auto-created by init container). LISTMONK_API_TOKEN \u2014 Token for API user. Generate with openssl rand -hex 16. LISTMONK_ADMIN_USER v2-api Same as LISTMONK_API_USER (used by the sync service). LISTMONK_ADMIN_PASSWORD \u2014 Same as LISTMONK_API_TOKEN. LISTMONK_SYNC_ENABLED false Set to true to sync participants/locations/users to Listmonk lists. LISTMONK_PROXY_PORT 9002 Nginx proxy port for Listmonk. Listmonk SMTP settings

      Listmonk has its own SMTP configuration, separate from the main platform's:

      Variable Default Description LISTMONK_SMTP_HOST mailhog-changemaker SMTP host for Listmonk. LISTMONK_SMTP_PORT 1025 SMTP port. LISTMONK_SMTP_USER (empty) SMTP username. LISTMONK_SMTP_PASSWORD (empty) SMTP password. LISTMONK_SMTP_TLS_TYPE none TLS mode: none, STARTTLS, or TLS. LISTMONK_SMTP_FROM Changemaker Lite <noreply@cmlite.org> From address for newsletters."},{"location":"docs/getting-started/environment-variables/#represent-api-canadian-electoral-data","title":"Represent API (Canadian Electoral Data)","text":"Variable Default Description REPRESENT_API_URL https://represent.opennorth.ca OpenNorth Represent API endpoint. Used for postal code \u2192 representative lookups. No API key required."},{"location":"docs/getting-started/environment-variables/#nocodb-data-browser","title":"NocoDB (Data Browser)","text":"

      Read-only database browser. Useful for inspecting data without SQL.

      Variable Default Description NOCODB_V2_PORT / NOCODB_PORT 8091 Host port for the NocoDB web UI. NOCODB_URL http://changemaker-v2-nocodb:8080 Internal Docker URL. NC_ADMIN_EMAIL admin@cmlite.org NocoDB admin email. NC_ADMIN_PASSWORD \u2014 NocoDB admin password."},{"location":"docs/getting-started/environment-variables/#media-manager","title":"Media Manager","text":"

      Video library with upload, analytics, scheduling, and a public gallery.

      Variable Default Description ENABLE_MEDIA_FEATURES false Set to true to enable the media system. MEDIA_API_PORT 4100 Fastify media API port. MEDIA_API_PUBLIC_URL http://media-api:4100 Internal URL for the media API container. MEDIA_ROOT /media/library Path to the video library inside the container. MEDIA_UPLOADS /media/uploads Path for upload processing. MAX_UPLOAD_SIZE_GB 10 Maximum single-file upload size in gigabytes. PUBLIC_MEDIA_PORT 3100 Public media gallery server port. VIDEO_PLAYER_DEBUG false Enable verbose video player logging. Analytics & scheduling settings Variable Default Description VIDEO_ANALYTICS_RETENTION_DAYS 90 Days to retain analytics data. GDPR-compliant with IP hashing. VIDEO_ANALYTICS_IP_HASHING_ENABLED true Hash viewer IPs for privacy. VIDEO_SCHEDULE_DEFAULT_TIMEZONE UTC Default timezone for scheduled publishing. VIDEO_SCHEDULE_NOTIFICATION_ENABLED true Notify on scheduled publish/unpublish. VIDEO_PREVIEW_LINK_EXPIRY_HOURS 24 Preview link JWT expiry (hours)."},{"location":"docs/getting-started/environment-variables/#gitea-git-hosting","title":"Gitea (Git Hosting)","text":"

      Self-hosted Git repository. Optional service.

      Variable Default Description GITEA_PORT / GITEA_WEB_PORT 3030 Gitea web UI port. GITEA_SSH_PORT 2222 Gitea SSH port for git operations. GITEA_DB_TYPE mysql Database type (Gitea uses its own MySQL). GITEA_DB_HOST gitea-db:3306 Internal database host. GITEA_DB_NAME gitea Database name. GITEA_DB_USER gitea Database user. GITEA_DB_PASSWD \u2014 Gitea database password. GITEA_DB_ROOT_PASSWORD \u2014 MySQL root password for Gitea. GITEA_ROOT_URL https://git.cmlite.org Public-facing URL for Gitea. GITEA_DOMAIN git.cmlite.org Domain used in git clone URLs."},{"location":"docs/getting-started/environment-variables/#n8n-workflow-automation","title":"n8n (Workflow Automation)","text":"Variable Default Description N8N_PORT 5678 n8n web UI port. N8N_HOST n8n.cmlite.org Public hostname for n8n. N8N_ENCRYPTION_KEY \u2014 Encryption key for n8n credentials storage. N8N_USER_EMAIL admin@example.com Initial n8n admin email. N8N_USER_PASSWORD \u2014 Initial n8n admin password. GENERIC_TIMEZONE UTC Timezone for n8n cron triggers."},{"location":"docs/getting-started/environment-variables/#mkdocs-documentation","title":"MkDocs (Documentation)","text":"Variable Default Description MKDOCS_PORT 4003 MkDocs dev server port (live preview). MKDOCS_SITE_SERVER_PORT 4004 MkDocs static site server port. BASE_DOMAIN https://cmlite.org Base URL for generated documentation links. MKDOCS_PREVIEW_URL http://mkdocs:8000 Internal container URL. MKDOCS_DOCS_PATH /mkdocs/docs Documentation source directory inside the container."},{"location":"docs/getting-started/environment-variables/#code-server-web-ide","title":"Code Server (Web IDE)","text":"Variable Default Description CODE_SERVER_PORT 8888 Code Server web UI port. CODE_SERVER_URL http://code-server:8080 Internal container URL. USER_NAME coder User account inside the Code Server container."},{"location":"docs/getting-started/environment-variables/#homepage-service-dashboard","title":"Homepage (Service Dashboard)","text":"Variable Default Description HOMEPAGE_PORT 3010 Homepage web UI port. HOMEPAGE_EMBED_PORT 8887 Port for iframe embedding in admin. HOMEPAGE_VAR_BASE_URL http://localhost Base URL used in Homepage service links."},{"location":"docs/getting-started/environment-variables/#mini-qr-qr-code-generator","title":"Mini QR (QR Code Generator)","text":"Variable Default Description MINI_QR_PORT 8089 Mini QR direct access port. MINI_QR_URL http://mini-qr:8080 Internal container URL. MINI_QR_EMBED_PORT 8885 Port for iframe embedding (walk sheets, cut exports)."},{"location":"docs/getting-started/environment-variables/#excalidraw-whiteboard","title":"Excalidraw (Whiteboard)","text":"Variable Default Description EXCALIDRAW_PORT 8090 Excalidraw web UI port. EXCALIDRAW_URL http://excalidraw-changemaker:80 Internal container URL. EXCALIDRAW_EMBED_PORT 8886 Port for iframe embedding. EXCALIDRAW_WS_URL wss://draw.cmlite.org WebSocket URL for real-time collaboration."},{"location":"docs/getting-started/environment-variables/#vaultwarden-password-manager","title":"Vaultwarden (Password Manager)","text":"

      Self-hosted Bitwarden-compatible password manager. Optional service.

      Variable Default Description VAULTWARDEN_PORT 8445 Vaultwarden web UI port. VAULTWARDEN_URL http://vaultwarden-changemaker:80 Internal container URL. VAULTWARDEN_EMBED_PORT 8890 Port for iframe embedding in admin. VAULTWARDEN_ADMIN_TOKEN (empty) Admin panel token (access at /admin). Generate with openssl rand -hex 32. VAULTWARDEN_DOMAIN https://vault.cmlite.org Public-facing URL. Must use HTTPS \u2014 Bitwarden web vault enforces HTTPS for account creation. Set to your Pangolin tunnel URL. VAULTWARDEN_SIGNUPS_ALLOWED false Allow new user self-registration. Keep false and use admin panel invites. VAULTWARDEN_WEBSOCKET_ENABLED true Enable WebSocket notifications for real-time sync. VAULTWARDEN_SMTP_SECURITY off SMTP security mode: off for MailHog, starttls or force_tls for production. Uses the main SMTP_* variables for host/credentials.

      Initial setup

      The vaultwarden-init container automatically invites the INITIAL_ADMIN_EMAIL user when starting. Check MailHog (or your SMTP) for the invitation email.

      "},{"location":"docs/getting-started/environment-variables/#rocketchat-team-chat","title":"Rocket.Chat (Team Chat)","text":"

      Self-hosted team chat for volunteer coordination. Requires MongoDB (auto-configured).

      Variable Default Description ENABLE_CHAT false Set to true to enable the Rocket.Chat integration. The initial default; once saved in admin Settings, the DB value is authoritative. ROCKETCHAT_ADMIN_USER rcadmin Rocket.Chat admin username. ROCKETCHAT_ADMIN_PASSWORD \u2014 Rocket.Chat admin password. ROCKETCHAT_URL http://rocketchat-changemaker:3000 Internal container URL. ROCKETCHAT_EMBED_PORT 8891 Port for iframe embedding in admin."},{"location":"docs/getting-started/environment-variables/#gancio-event-management","title":"Gancio (Event Management)","text":"

      Self-hosted event management platform. Uses the shared PostgreSQL database (auto-created by init-gancio-db.sh).

      Variable Default Description GANCIO_PORT 8092 Gancio web UI port. GANCIO_URL http://gancio-changemaker:13120 Internal container URL. GANCIO_EMBED_PORT 8892 Port for iframe embedding in admin. GANCIO_BASE_URL https://events.cmlite.org Public-facing URL for Gancio. Used in event links. GANCIO_ADMIN_USER admin Gancio admin username for shift-to-event sync (OAuth login). GANCIO_ADMIN_PASSWORD \u2014 Gancio admin password. GANCIO_SYNC_ENABLED false Set to true to enable automatic shift \u2192 Gancio event synchronization."},{"location":"docs/getting-started/environment-variables/#mailhog-development-email","title":"MailHog (Development Email)","text":"Variable Default Description MAILHOG_SMTP_PORT 1025 SMTP port for capturing emails. MAILHOG_WEB_PORT 8025 Web UI to view captured emails."},{"location":"docs/getting-started/environment-variables/#nar-national-address-register","title":"NAR (National Address Register)","text":"

      Canadian address data import for geographic canvassing.

      Variable Default Description NAR_DATA_DIR /data Path to extracted NAR data inside the container. Expects YYYYMM/Addresses/ and YYYYMM/Locations/ subdirectories. Mount via ./data:/data:ro in Docker Compose.

      Download NAR data from Statistics Canada.

      "},{"location":"docs/getting-started/environment-variables/#geocoding","title":"Geocoding","text":"

      Multi-provider geocoding for address resolution. Works out of the box with free providers; optional paid providers improve accuracy.

      Variable Default Description MAPBOX_API_KEY (empty) Mapbox API key for improved geocoding accuracy. Free tier: 100k requests/month. Sign up. GEOCODING_RATE_LIMIT_MS 1100 Delay between requests to free providers (ms). Respects rate limits. GEOCODING_CACHE_ENABLED true Enable Redis-backed geocoding cache. GEOCODING_CACHE_TTL_HOURS 24 Cache lifetime in hours. GOOGLE_MAPS_API_KEY (empty) Google Maps API key. Most accurate but $0.005/request after free tier. GOOGLE_MAPS_ENABLED false Enable Google Maps as a geocoding provider. GEOCODING_PARALLEL_ENABLED true Enable parallel geocoding for bulk imports (~10x speedup). GEOCODING_BATCH_SIZE 10 Number of concurrent geocoding requests during bulk operations. BULK_GEOCODE_ENABLED true Enable bulk re-geocoding from the admin UI. BULK_GEOCODE_MAX_BATCH 5000 Maximum locations per bulk geocoding run."},{"location":"docs/getting-started/environment-variables/#overpass-area-import","title":"Overpass / Area Import","text":"

      OpenStreetMap data import for map enrichment.

      Variable Default Description OVERPASS_API_URL https://overpass-api.de/api/interpreter Overpass API endpoint. Use a private instance for heavy usage. OVERPASS_MIN_DELAY_MS 30000 Minimum delay between requests (ms). The public API requires 30 seconds. AREA_IMPORT_MAX_GRID_POINTS 500 Maximum reverse-geocode grid points per area import."},{"location":"docs/getting-started/environment-variables/#pangolin-tunnel","title":"Pangolin Tunnel","text":"

      Expose services to the internet without port forwarding, using a self-hosted Pangolin instance.

      Variable Default Description PANGOLIN_API_URL https://api.bnkserve.org/v1 Pangolin server API endpoint. PANGOLIN_API_KEY (empty) API key for Pangolin management. PANGOLIN_ORG_ID (empty) Organization ID in Pangolin. PANGOLIN_SITE_ID (empty) Site ID (populated after setup via admin GUI). PANGOLIN_ENDPOINT https://pangolin.bnkserve.org Pangolin tunnel endpoint. PANGOLIN_NEWT_ID (empty) Newt client ID (populated after setup). PANGOLIN_NEWT_SECRET (empty) Newt client secret (populated after setup).

      Setup flow

      Configure the tunnel from Admin \u2192 Settings \u2192 Pangolin. The setup wizard walks you through creating a site, copying credentials, and connecting the Newt container. See Deployment for the full guide.

      "},{"location":"docs/getting-started/environment-variables/#monitoring","title":"Monitoring","text":"

      These services are behind the monitoring Docker Compose profile. Start them with:

      docker compose --profile monitoring up -d\n
      Variable Default Description PROMETHEUS_PORT 9090 Prometheus web UI / query port. GRAFANA_PORT 3005 Grafana dashboard port. GRAFANA_ADMIN_PASSWORD admin Change in production. GRAFANA_ROOT_URL http://localhost:3005 Public URL for Grafana (used in links). CADVISOR_PORT 8086 cAdvisor container metrics port. NODE_EXPORTER_PORT 9100 Prometheus node exporter port. REDIS_EXPORTER_PORT 9121 Redis metrics exporter port. ALERTMANAGER_PORT 9093 Alertmanager web UI port. GOTIFY_PORT 8889 Gotify push notification port. GOTIFY_ADMIN_USER admin Gotify admin username. GOTIFY_ADMIN_PASSWORD admin Change in production."},{"location":"docs/getting-started/environment-variables/#generating-secrets","title":"Generating Secrets","text":"

      Use these commands to generate all required secrets at once:

      # JWT secrets (two separate values)\necho \"JWT_ACCESS_SECRET=$(openssl rand -hex 32)\"\necho \"JWT_REFRESH_SECRET=$(openssl rand -hex 32)\"\n\n# Encryption key (must differ from JWT secrets)\necho \"ENCRYPTION_KEY=$(openssl rand -hex 32)\"\n\n# Database and Redis passwords\necho \"V2_POSTGRES_PASSWORD=$(openssl rand -hex 24)\"\necho \"REDIS_PASSWORD=$(openssl rand -hex 24)\"\n\n# Listmonk\necho \"LISTMONK_DB_PASSWORD=$(openssl rand -hex 24)\"\necho \"LISTMONK_WEB_ADMIN_PASSWORD=$(openssl rand -hex 16)\"\nLISTMONK_TOKEN=$(openssl rand -hex 16)\necho \"LISTMONK_API_TOKEN=$LISTMONK_TOKEN\"\necho \"LISTMONK_ADMIN_PASSWORD=$LISTMONK_TOKEN\"\n\n# Supporting services\necho \"GITEA_DB_PASSWD=$(openssl rand -hex 24)\"\necho \"GITEA_DB_ROOT_PASSWORD=$(openssl rand -hex 24)\"\necho \"N8N_ENCRYPTION_KEY=$(openssl rand -hex 32)\"\necho \"N8N_USER_PASSWORD=$(openssl rand -hex 16)\"\necho \"NC_ADMIN_PASSWORD=$(openssl rand -hex 16)\"\necho \"INITIAL_ADMIN_PASSWORD=$(openssl rand -base64 18)\"\n\n# Vaultwarden\necho \"VAULTWARDEN_ADMIN_TOKEN=$(openssl rand -hex 32)\"\n\n# Rocket.Chat\necho \"ROCKETCHAT_ADMIN_PASSWORD=$(openssl rand -hex 16)\"\n\n# Gancio\necho \"GANCIO_ADMIN_PASSWORD=$(openssl rand -hex 16)\"\n

      Tip

      Copy the output and paste the values into your .env file. The INITIAL_ADMIN_PASSWORD uses base64 encoding to ensure it contains uppercase, lowercase, and digits (meeting the password policy).

      "},{"location":"docs/getting-started/environment-variables/#minimal-vs-full-deployment","title":"Minimal vs Full Deployment","text":"Minimal (Core Only)Full Stack

      For a basic deployment with campaigns, map, and admin:

      Required variables
      V2_POSTGRES_PASSWORD=...\nREDIS_PASSWORD=...\nJWT_ACCESS_SECRET=...\nJWT_REFRESH_SECRET=...\nENCRYPTION_KEY=...\nINITIAL_ADMIN_PASSWORD=...\n
      Start services
      docker compose up -d v2-postgres redis api admin\n

      For the complete platform including media, newsletters, monitoring, and all services:

      Additional variables needed
      # Everything above, plus:\nENABLE_MEDIA_FEATURES=true\nENABLE_PAYMENTS=true\nENABLE_CHAT=true\nLISTMONK_SYNC_ENABLED=true\nGANCIO_SYNC_ENABLED=true\nLISTMONK_DB_PASSWORD=...\nLISTMONK_WEB_ADMIN_PASSWORD=...\nLISTMONK_API_TOKEN=...\nNC_ADMIN_PASSWORD=...\nGITEA_DB_PASSWD=...\nGITEA_DB_ROOT_PASSWORD=...\nN8N_ENCRYPTION_KEY=...\nN8N_USER_PASSWORD=...\nVAULTWARDEN_ADMIN_TOKEN=...\nROCKETCHAT_ADMIN_PASSWORD=...\nGANCIO_ADMIN_PASSWORD=...\nEMAIL_TEST_MODE=false\nSMTP_HOST=smtp.your-provider.com\nSMTP_PORT=587\nSMTP_USER=you@example.com\nSMTP_PASS=your-smtp-password\n
      Start services
      docker compose up -d\ndocker compose --profile monitoring up -d\n
      "},{"location":"docs/getting-started/features/","title":"Features at a Glance","text":"

      Changemaker Lite bundles advocacy campaigns, geographic mapping, volunteer management, media hosting, and landing pages into a single self-hosted platform. Every feature can be toggled on or off from Settings in the admin panel.

      "},{"location":"docs/getting-started/features/#core-features","title":"Core Features","text":"
      • Advocacy Campaigns

        Help supporters contact elected representatives through email campaigns with postal code lookup and a public response wall.

        Campaign guide

      • Map & Canvassing

        Manage locations, draw canvassing territories, schedule volunteer shifts, and run GPS-tracked door-to-door outreach.

        Map guide

      • Media Manager

        Upload videos and photos, curate playlists, publish a shorts feed, and track engagement with built-in analytics.

        Media guide

      • Landing Pages

        Build campaign microsites with a drag-and-drop GrapesJS visual editor and publish at custom slugs.

        Landing pages guide

      • Payments (Stripe)

        Accept memberships, product sales, and donations with encrypted Stripe integration and branded donation pages.

        Payments guide

      • SMS Campaigns

        Text message outreach via a Termux Android bridge with contact lists, templates, and response tracking.

        SMS guide

      • Public Homepage

        Customizable landing page with hero section, live stats, featured campaigns, upcoming shifts, and activity feed.

        Homepage guide

      "},{"location":"docs/getting-started/features/#communication-collaboration","title":"Communication & Collaboration","text":"
      • Newsletter (Listmonk)

        Opt-in mailing lists and newsletter campaigns with automatic subscriber sync from shifts and contacts.

        Newsletter guide

      • Email Templates

        Reusable email templates with variable substitution for campaign communications.

        Email templates guide

      • Team Chat (Rocket.Chat)

        Self-hosted team chat with iframe integration, floating widget, and native mobile app support.

        Chat guide

      • Video Conferencing (Jitsi)

        Self-hosted video calls integrated with Rocket.Chat via JWT authentication \u2014 no separate login required.

        Video conferencing guide

      "},{"location":"docs/getting-started/features/#integrations-services","title":"Integrations & Services","text":"
      • Events (Gancio)

        Self-hosted event management with automatic shift-to-event sync and an embeddable calendar widget.

        Events guide

      • Password Manager (Vaultwarden)

        Bitwarden-compatible password vault for secure team credential sharing.

        Password manager guide

      • User Provisioning

        Automatic account creation and sync across Rocket.Chat, Gitea, Vaultwarden, and Listmonk.

        User provisioning guide

      • People / Contacts

        Centralized contact management for supporters, donors, and community members with cross-module linking.

        People guide

      • Whiteboard (Excalidraw)

        Self-hosted collaborative whiteboard for brainstorming, planning, and visual collaboration.

        Whiteboard guide

      "},{"location":"docs/getting-started/features/#volunteer-portal","title":"Volunteer Portal","text":"
      • Social Connections

        Friend system, activity feed, groups, profiles, pokes, and privacy controls for volunteer community building.

        Social guide

      • Achievements & Leaderboard

        Badge system with 11 achievements across 4 categories, progress tracking, and competitive leaderboards.

        Achievements guide

      • Volunteer Quick Join

        QR code invite links for instant volunteer onboarding \u2014 scan, fill a short form, and start canvassing.

        Quick join guide

      "},{"location":"docs/getting-started/features/#automation-analytics","title":"Automation & Analytics","text":"
      • Email Automation

        Automated volunteer lifecycle emails \u2014 thank-you notes, shift reminders, weekly summaries, and re-engagement campaigns.

        Automation guide

      • Data Quality Dashboard

        Geocoding quality metrics with per-provider stats, confidence tiers, and coverage analysis.

        Data quality guide

      • Documentation Analytics

        Page view tracking and engagement metrics for MkDocs documentation pages.

        Docs analytics guide

      "},{"location":"docs/getting-started/features/#admin-tools","title":"Admin Tools","text":"
      • Docs Comments

        Gitea-backed comment system for documentation pages with anonymous posting and moderation.

        Docs comments guide

      • Command Palette

        Global Ctrl+K search across pages, campaigns, locations, users, settings, and media.

        Command palette guide

      • Navigation Settings

        Customize the public navigation menu with feature toggles, custom links, and drag-and-drop reordering.

        Navigation guide

      • Platform Settings

        Five-tab settings page covering organization details, theme colors, email configuration, feature flags, and notifications.

        Settings guide

      • Social Sharing (OG Tags)

        Open Graph meta tags for campaigns, landing pages, and gallery videos \u2014 rich link previews on social media.

        OG sharing guide

      • Gallery Ads

        Internal ad system with 5 ad types, audience targeting, scheduling, frequency caps, and CTR analytics.

        Gallery ads guide

      • Self-Service Contact Profile

        Token-based public profile pages where contacts can view and update their information and preferences.

        Contact profile guide

      "},{"location":"docs/getting-started/first-steps/","title":"First Steps","text":"

      You've installed Changemaker Lite \u2014 here's what to do next.

      "},{"location":"docs/getting-started/first-steps/#1-log-in","title":"1. Log In","text":"

      Open the admin panel at http://localhost:3000 (or app.DOMAIN in production) and sign in with the admin email and password you configured during setup.

      Change your password

      If you used the wizard's generated password, change it immediately from Settings > Organization.

      "},{"location":"docs/getting-started/first-steps/#2-explore-the-dashboard","title":"2. Explore the Dashboard","text":"

      The dashboard gives you an at-a-glance view of platform activity. Initially it will be empty \u2014 that's normal.

      "},{"location":"docs/getting-started/first-steps/#3-configure-settings","title":"3. Configure Settings","text":"

      Visit Settings (/app/settings) to:

      • Set your organization name, logo, and tagline
      • Choose theme colors for admin and public interfaces
      • Enable feature modules (campaigns, map, media, payments, etc.)
      • Configure email delivery (MailHog for testing, production SMTP for live use)
      "},{"location":"docs/getting-started/first-steps/#4-create-your-first-campaign","title":"4. Create Your First Campaign","text":"

      Go to Campaigns (/app/campaigns) and click Create Campaign:

      1. Write a title and description
      2. Compose the email template supporters will send
      3. Select government levels to target
      4. Publish \u2014 the campaign appears at /campaigns
      "},{"location":"docs/getting-started/first-steps/#5-add-locations","title":"5. Add Locations","text":"

      Go to Locations (/app/map) and add addresses:

      • Click on the map to drop a marker
      • Import a CSV of addresses
      • Use the NAR (National Address Register) import for Canadian data
      "},{"location":"docs/getting-started/first-steps/#6-schedule-a-shift","title":"6. Schedule a Shift","text":"

      Go to Shifts (/app/map/shifts) and create your first volunteer shift:

      1. Set a date, time, and location description
      2. Optionally link it to a canvassing area
      3. Share the public shifts page (/shifts) with volunteers
      "},{"location":"docs/getting-started/first-steps/#7-invite-volunteers","title":"7. Invite Volunteers","text":"

      Share the shifts page link or generate QR codes for in-person events. Volunteers sign up with just an email address.

      "},{"location":"docs/getting-started/first-steps/#next-steps","title":"Next Steps","text":"
      • Features at a Glance \u2014 visual overview of every module
      • Admin Guide \u2014 full administration reference
      • Deployment \u2014 production setup with tunneling and SSL
      "},{"location":"docs/getting-started/installation/","title":"Installation","text":"

      Changemaker Lite runs as a set of Docker containers orchestrated by Docker Compose.

      "},{"location":"docs/getting-started/installation/#prerequisites","title":"Prerequisites","text":"
      • Docker 24+ and Docker Compose v2
      • OpenSSL (for secret generation)
      • A Linux server (Ubuntu 22.04+ recommended) or macOS for development
      • At least 2 GB RAM and 10 GB disk space
      • A domain name (optional, but recommended for production)
      "},{"location":"docs/getting-started/installation/#quick-start","title":"Quick Start","text":"
      # Clone the repository\ngit clone https://gitea.bnkops.com/admin/changemaker.lite\ncd changemaker.lite\ngit checkout v2\n\n# Run the configuration wizard\nbash config.sh\n\n# Start core services\ndocker compose up -d v2-postgres redis api admin\n\n# Run database migrations and seed\ndocker compose exec api npx prisma migrate deploy\ndocker compose exec api npx prisma db seed\n

      Open http://localhost:3000 and sign in with the admin credentials you configured.

      "},{"location":"docs/getting-started/installation/#configuration-wizard","title":"Configuration Wizard","text":"

      The config.sh wizard walks you through domain setup, admin credentials, secret generation, SMTP config, feature flags, and Pangolin tunnel setup. After completion you'll have a fully populated .env with no placeholder passwords.

      "},{"location":"docs/getting-started/installation/#manual-setup","title":"Manual Setup","text":"

      If you prefer to configure by hand, copy .env.example to .env and set the required values. See Environment Variables for every option.

      "},{"location":"docs/getting-started/installation/#next-steps","title":"Next Steps","text":"
      • First Steps \u2014 explore the dashboard and create your first campaign
      • Environment Variables \u2014 complete configuration reference
      "},{"location":"docs/services/","title":"Services","text":"

      Changemaker Lite orchestrates 20+ services via Docker Compose. This page is your map to every service: what it does, how to reach it, and where to find its upstream documentation.

      "},{"location":"docs/services/#core-platform","title":"Core Platform","text":"

      The essential services that power the application.

      • Express API

        Main V2 API server. Handles authentication, campaigns, map, shifts, pages, email, and all business logic. Prisma ORM with PostgreSQL.

        Port: 4000 \u00b7 Container: changemaker-v2-api

        API Reference

      • Fastify Media API

        Video library server. Upload, metadata extraction (FFprobe), analytics, scheduled publishing, and public gallery. Shares the same PostgreSQL database.

        Port: 4100 \u00b7 Container: changemaker-media-api

        Media Guide

      • Admin GUI

        React single-page application (Vite + Ant Design + Zustand). Serves the admin dashboard, public campaign pages, volunteer portal, and media gallery \u2014 all from one build.

        Port: 3000 \u00b7 Container: changemaker-v2-admin

        Feature Guides

      • PostgreSQL 16

        Primary database shared by both APIs. Managed by Prisma migrations. Contains 30+ tables covering users, campaigns, locations, shifts, media, and more.

        Port: 5433 (host) / 5432 (container) \u00b7 Container: changemaker-v2-postgres

        PostgreSQL Docs

      • Redis

        In-memory store for rate limiting, BullMQ job queues (email, video scheduling), geocoding cache, and session data. Requires authentication.

        Port: 6379 \u00b7 Container: redis-changemaker

        Redis Docs

      • Nginx

        Reverse proxy handling all subdomain routing (app., api., media., docs., etc.). Includes security headers (HSTS, CSP, Permissions-Policy) and WebSocket support.

        Port: 80 / 443 \u00b7 Container: changemaker-v2-nginx

        Nginx Docs

      "},{"location":"docs/services/#communication-email","title":"Communication & Email","text":"
      • Listmonk

        Self-hosted newsletter and mailing list manager. Drop-in replacement for Mailchimp. Opt-in sync with the main platform imports participants, locations, and users as subscriber lists.

        Port: 9001 \u00b7 Container: listmonk-app \u00b7 Subdomain: listmonk.DOMAIN

        Listmonk Docs

      • MailHog

        Email capture for development. All outgoing email is intercepted and displayed in a web UI when EMAIL_TEST_MODE=true. No real emails are sent.

        Port: 8025 (web) / 1025 (SMTP) \u00b7 Container: mailhog-changemaker \u00b7 Subdomain: mail.DOMAIN

        MailHog GitHub

      "},{"location":"docs/services/#content-editing","title":"Content & Editing","text":"
      • MkDocs

        Material-themed documentation site with full-text search, blog, social cards, and Jinja2 template overrides. Two containers: live preview (dev) and static site (production).

        Port: 4003 (dev) / 4004 (static) \u00b7 Container: mkdocs-changemaker \u00b7 Subdomain: docs.DOMAIN

        MkDocs Material

      • Code Server

        Full VS Code in the browser. Edit configuration files, templates, and documentation from anywhere without SSH. Supports extensions.

        Port: 8888 \u00b7 Container: code-server-changemaker \u00b7 Subdomain: code.DOMAIN

        Code Server Docs

      "},{"location":"docs/services/#data-automation","title":"Data & Automation","text":"
      • NocoDB

        Airtable-alternative database browser. Provides a spreadsheet-like interface to browse, filter, sort, and export campaign data. Read-only access to the main database.

        Port: 8091 \u00b7 Container: changemaker-v2-nocodb \u00b7 Subdomain: db.DOMAIN

        NocoDB Docs

      • n8n

        Visual workflow automation platform. Connect APIs, trigger actions on events, schedule tasks, and build custom integrations \u2014 all without code. 400+ built-in integrations.

        Port: 5678 \u00b7 Container: n8n-changemaker \u00b7 Subdomain: n8n.DOMAIN

        n8n Docs

      • Gitea

        Self-hosted Git repository hosting. Version control for campaign code, configuration, templates, and documentation. Includes issues, pull requests, and CI/CD.

        Port: 3030 (web) / 2222 (SSH) \u00b7 Container: gitea-changemaker \u00b7 Subdomain: git.DOMAIN

        Gitea Docs

      "},{"location":"docs/services/#utilities","title":"Utilities","text":"
      • Mini QR

        Lightweight QR code generator. Produces PNG images for walk sheets, campaign materials, and event signage. Embedded in the admin dashboard via iframe.

        Port: 8089 \u00b7 Container: mini-qr \u00b7 Subdomain: qr.DOMAIN

      • Homepage

        Service dashboard showing the status of all containers at a glance. Auto-generated services.yaml from config.sh provides both production and local links.

        Port: 3010 \u00b7 Container: homepage-changemaker \u00b7 Subdomain: home.DOMAIN

        Homepage Docs

      • Excalidraw

        Collaborative whiteboard for brainstorming, diagramming, and visual planning. Real-time collaboration via WebSocket.

        Port: 8090 \u00b7 Container: excalidraw-changemaker \u00b7 Subdomain: draw.DOMAIN

        Excalidraw

      • Vaultwarden

        Self-hosted Bitwarden-compatible password manager. Secure credential sharing for campaign teams. Requires HTTPS for account creation; local browsing works on HTTP.

        Port: 8445 \u00b7 Container: vaultwarden-changemaker \u00b7 Subdomain: vault.DOMAIN

        Vaultwarden Wiki

      "},{"location":"docs/services/#team-communication","title":"Team Communication","text":"
      • Rocket.Chat

        Self-hosted team chat for volunteer coordination. Supports channels, direct messaging, threads, and file sharing. Embeddable in the admin dashboard via iframe. Enable with ENABLE_CHAT=true.

        Port: 3000 (internal) \u00b7 Container: rocketchat-changemaker \u00b7 Subdomain: chat.DOMAIN

        Rocket.Chat Docs

      • Jitsi Meet

        Self-hosted video conferencing with JWT authentication. Four containers (web, Prosody, Jicofo, JVB) provide the full video call stack. Integrated with Rocket.Chat for one-click calls from channels and DMs. Enable with ENABLE_MEET=true.

        Containers: jitsi-web, jitsi-prosody, jitsi-jicofo, jitsi-jvb \u00b7 Subdomain: meet.DOMAIN

        Setup Guide \u00b7 Jitsi Docs

      • Gancio

        Self-hosted event management platform. Automatic shift-to-event sync (when GANCIO_SYNC_ENABLED=true) publishes shifts as public events. Uses the shared PostgreSQL database. Embeddable calendar widget available for MkDocs pages.

        Port: 8092 \u00b7 Container: gancio-changemaker \u00b7 Subdomain: events.DOMAIN

        Gancio Docs

      "},{"location":"docs/services/#networking-tunneling","title":"Networking & Tunneling","text":"
      • Pangolin + Newt

        Self-hosted tunnel server with the Newt client container. Exposes your services to the internet without port forwarding. Handles SSL/TLS, works behind CGNAT and double NAT.

        Container: newt-changemaker \u00b7 Managed from Admin \u2192 Settings \u2192 Tunnel

        Deployment Guide \u00b7 Pangolin GitHub

      "},{"location":"docs/services/#monitoring-stack","title":"Monitoring Stack","text":"

      These services run behind the monitoring Docker Compose profile. Start them with:

      docker compose --profile monitoring up -d\n
      • Prometheus

        Metrics collection and time-series database. Scrapes 12 custom cm_* application metrics plus container, host, and Redis metrics. Pre-configured alert rules.

        Port: 9090 \u00b7 Container: prometheus-changemaker

        Prometheus Docs

      • Grafana

        Metrics visualization with 3 auto-provisioned dashboards: API Overview, Infrastructure, and Campaign Activity. Supports custom dashboards and alerting.

        Port: 3005 \u00b7 Container: grafana-changemaker \u00b7 Subdomain: grafana.DOMAIN

        Grafana Docs

      • Alertmanager

        Alert routing and notification delivery. Receives alerts from Prometheus and dispatches to Gotify, email, or webhooks based on configurable rules.

        Port: 9093 \u00b7 Container: alertmanager-changemaker

        Alertmanager Docs

      • cAdvisor

        Container resource metrics. Exposes CPU, memory, network, and filesystem usage per container for Prometheus to scrape.

        Port: 8086 \u00b7 Container: cadvisor-changemaker

        cAdvisor GitHub

      • Node Exporter

        Host system metrics. Reports CPU, memory, disk, and network stats for the underlying server.

        Port: 9100 \u00b7 Container: node-exporter-changemaker

        Node Exporter

      • Redis Exporter

        Redis metrics for Prometheus. Exposes connection counts, memory usage, command stats, and keyspace info.

        Port: 9121 \u00b7 Container: redis-exporter-changemaker

        Redis Exporter GitHub

      • Gotify

        Self-hosted push notification server. Receives alerts from Alertmanager and delivers them to mobile/desktop clients.

        Port: 8889 \u00b7 Container: gotify-changemaker

        Gotify Docs

      "},{"location":"docs/services/#quick-reference","title":"Quick Reference","text":"

      All services at a glance with their default ports and subdomains.

      Service Port Subdomain Docker Profile Express API 4000 api. default Media API 4100 media. default Admin GUI 3000 app. default PostgreSQL 5433 \u2014 default Redis 6379 \u2014 default Nginx 80/443 (all) default Listmonk 9001 listmonk. default MailHog 8025 mail. default MkDocs (dev) 4003 docs. default MkDocs (static) 4004 (root) default Code Server 8888 code. default NocoDB 8091 db. default n8n 5678 n8n. default Gitea 3030 git. default Mini QR 8089 qr. default Homepage 3010 home. default Excalidraw 8090 draw. default Vaultwarden 8445 vault. default Rocket.Chat \u2014 chat. default Jitsi Meet \u2014 meet. default Gancio 8092 events. default Newt (tunnel) \u2014 \u2014 default Prometheus 9090 \u2014 monitoring Grafana 3005 grafana. monitoring Alertmanager 9093 \u2014 monitoring cAdvisor 8086 \u2014 monitoring Node Exporter 9100 \u2014 monitoring Redis Exporter 9121 \u2014 monitoring Gotify 8889 \u2014 monitoring

      Starting services selectively

      You don't need to run everything. Start only what you need:

      # Core only\ndocker compose up -d v2-postgres redis api admin\n\n# Add nginx for subdomain routing\ndocker compose up -d nginx\n\n# Add monitoring\ndocker compose --profile monitoring up -d\n

      See Getting Started for the recommended startup order.

      "},{"location":"docs/troubleshooting/","title":"Troubleshooting","text":"

      Common issues and their solutions when running Changemaker Lite.

      Under Construction

      This troubleshooting guide is being expanded. Check back soon for more solutions.

      "},{"location":"docs/troubleshooting/#cors-errors-in-production","title":"CORS Errors in Production","text":"

      Symptom: Browser console shows CORS errors when accessing production domain.

      Fix: Add your production domain to CORS_ORIGINS in .env:

      CORS_ORIGINS=https://app.yourdomain.org,http://localhost:3000\n

      Then restart the API: docker compose restart api

      "},{"location":"docs/troubleshooting/#pangolin-tunnel-403302-errors","title":"Pangolin Tunnel 403/302 Errors","text":"

      Symptom: All API endpoints return 302 redirects to Pangolin auth page.

      Fix: In the Pangolin dashboard, set each resource to \"Not Protected\" (public access).

      "},{"location":"docs/troubleshooting/#database-connection-failures","title":"Database Connection Failures","text":"
      1. Check PostgreSQL: docker compose ps v2-postgres
      2. Verify DATABASE_URL in .env
      3. View logs: docker compose logs v2-postgres --tail 50
      "},{"location":"docs/troubleshooting/#redis-connection-failures","title":"Redis Connection Failures","text":"
      1. Check Redis: docker compose ps redis-changemaker
      2. Verify REDIS_PASSWORD and REDIS_URL format in .env
      3. Test: docker compose exec redis-changemaker redis-cli -a $REDIS_PASSWORD ping
      "},{"location":"docs/troubleshooting/#api-not-starting","title":"API Not Starting","text":"
      1. Check logs: docker compose logs api --tail 100
      2. Verify all required env vars are set (see .env.example)
      3. Run migrations: docker compose exec api npx prisma migrate deploy
      "},{"location":"docs/user-guide/","title":"User Guide","text":"

      This guide covers everything you can do as a visitor or registered supporter \u2014 from contacting representatives to signing up for volunteer shifts.

      "},{"location":"docs/user-guide/#what-you-can-do","title":"What You Can Do","text":"
      • Campaigns

        Find active advocacy campaigns, look up your representatives, and send emails.

      • Map

        Explore the interactive map showing locations across your community.

      • Shifts

        Sign up for volunteer shifts and join canvassing teams.

      • Events

        Browse upcoming events and RSVP through the event calendar.

      • Gallery

        Watch campaign videos, browse photos, and explore playlists.

      • Shop & Pricing

        Purchase campaign merchandise or subscribe to a membership plan.

      • Donations

        Support the cause with one-time donations on branded pages.

      • Your Profile

        View and manage your contact profile, preferences, and activity history.

      "},{"location":"docs/user-guide/campaigns/","title":"Campaigns","text":"

      Browse active advocacy campaigns and contact your elected representatives.

      "},{"location":"docs/user-guide/campaigns/#how-it-works","title":"How It Works","text":"
      1. Browse campaigns at /campaigns \u2014 see active campaigns with descriptions and email counts
      2. Pick a campaign \u2014 read about the issue and who it targets
      3. Enter your postal code \u2014 the system looks up your federal, provincial, municipal, and school board representatives
      4. Send the email \u2014 use \"Send Now\" to send through the platform, or open it in your own email app (Gmail, Outlook, etc.)
      5. Share the response \u2014 if a representative replies, submit it to the public Response Wall
      "},{"location":"docs/user-guide/campaigns/#response-wall","title":"Response Wall","text":"

      Each campaign has a public response wall where supporters share how their representatives responded. Responses can be upvoted and are moderated by admins. Verified responses display a trust badge.

      "},{"location":"docs/user-guide/campaigns/#submit-your-own-campaign","title":"Submit Your Own Campaign","text":"

      Registered users can draft and submit their own advocacy campaigns at /campaigns/create. Submissions go through admin review before being published.

      "},{"location":"docs/user-guide/campaigns/#public-routes","title":"Public Routes","text":"
      • /campaigns \u2014 browse active campaigns
      • /campaign/:slug \u2014 take action on a specific campaign
      • /campaign/:slug/responses \u2014 view the response wall
      • /campaigns/create \u2014 submit a user-generated campaign (requires login)
      • /campaigns/mine \u2014 manage your submitted campaigns (requires login)
      "},{"location":"docs/user-guide/donations/","title":"Donations","text":"

      Make one-time contributions on branded donation pages.

      "},{"location":"docs/user-guide/donations/#how-it-works","title":"How It Works","text":"
      1. Browse donation pages at /donate
      2. Choose a campaign to support
      3. Select a suggested amount or enter a custom amount
      4. Complete payment securely through Stripe
      5. Receive a confirmation with a thank-you message
      "},{"location":"docs/user-guide/donations/#donation-pages","title":"Donation Pages","text":"

      Each donation page has:

      • Custom branding \u2014 unique title, description, and cover image
      • Suggested amounts \u2014 pre-set donation tiers for quick selection
      • Goal tracking \u2014 progress bar showing how close the campaign is to its fundraising goal
      • Anonymous giving \u2014 option to donate without displaying your name
      "},{"location":"docs/user-guide/donations/#public-routes","title":"Public Routes","text":"
      • /donate \u2014 browse donation pages
      • /donate/:slug \u2014 donate on a specific campaign page
      "},{"location":"docs/user-guide/events/","title":"Events (Gancio)","text":"

      Integrated with Gancio for self-hosted event management. When enabled, volunteer shifts are automatically published as public events.

      "},{"location":"docs/user-guide/events/#shift-to-event-sync","title":"Shift-to-Event Sync","text":"

      When GANCIO_SYNC_ENABLED=true, the platform:

      1. Creates a Gancio event whenever a new shift is published
      2. Updates the event if the shift time, location, or details change
      3. Deletes the event if the shift is cancelled

      Sync uses OAuth authentication with the Gancio admin account.

      "},{"location":"docs/user-guide/events/#key-features","title":"Key Features","text":"
      • Automatic sync \u2014 shifts appear as public events without manual entry
      • Embeddable calendar \u2014 GrapesJS block and MkDocs widget for embedding the event calendar on pages
      • Public events page \u2014 linked from the public navigation when enableEvents is enabled in settings
      "},{"location":"docs/user-guide/events/#admin-routes","title":"Admin Routes","text":"
      • /app/gancio \u2014 Gancio service status and iframe embed
      "},{"location":"docs/user-guide/events/#public-routes","title":"Public Routes","text":"
      • /events \u2014 public events navigation link (when enabled)
      • events.DOMAIN \u2014 Gancio web interface for browsing and RSVPs
      "},{"location":"docs/user-guide/gallery/","title":"Gallery","text":"

      The public gallery at /gallery showcases campaign videos, photos, and curated playlists.

      "},{"location":"docs/user-guide/gallery/#videos","title":"Videos","text":"
      • Browse by category \u2014 videos organized into categories with thumbnails and durations
      • Video player \u2014 full playback with engagement features (reactions, comments)
      • Shorts feed \u2014 TikTok-style vertical video feed for clips under 60 seconds at /gallery/shorts
      "},{"location":"docs/user-guide/gallery/#photos","title":"Photos","text":"
      • Photo albums \u2014 browse photos organized into named collections
      • Reactions and comments \u2014 engage with individual photos
      "},{"location":"docs/user-guide/gallery/#playlists","title":"Playlists","text":"
      • Curated playlists \u2014 admin and community-created video collections
      • Featured carousel \u2014 highlighted playlists on the gallery homepage
      • Playlist viewer \u2014 continuous playback with up-next queue at /gallery/playlist/:id
      "},{"location":"docs/user-guide/gallery/#public-routes","title":"Public Routes","text":"
      • /gallery \u2014 public video and photo gallery
      • /gallery/watch/:id \u2014 watch a specific video
      • /gallery/playlist/:id \u2014 view a playlist
      • /gallery/shorts \u2014 browse the shorts feed
      "},{"location":"docs/user-guide/map/","title":"Map","text":"

      The public map at /map shows locations across your community on an interactive Leaflet map.

      "},{"location":"docs/user-guide/map/#features","title":"Features","text":"
      • Interactive map \u2014 zoom, pan, and click markers to see address details
      • Color-coded markers \u2014 locations are color-coded based on their status
      • Cluster groups \u2014 markers group together when zoomed out for better performance
      • Fullscreen mode \u2014 expand the map to fill your screen
      "},{"location":"docs/user-guide/map/#public-routes","title":"Public Routes","text":"
      • /map \u2014 public interactive map
      "},{"location":"docs/user-guide/profile/","title":"Self-Service Contact Profile","text":"

      Give supporters a private, token-based link to view and manage their own contact profile -- no login required.

      "},{"location":"docs/user-guide/profile/#how-it-works","title":"How It Works","text":"
      1. An admin generates a profile link from the People CRM -- each link contains a unique 64-character hex token with a configurable expiration (24 hours to 1 year).
      2. The supporter opens the link -- if the link is password-protected, they enter the password first. If expired, they see a branded expiration notice.
      3. The supporter views and edits their profile -- they can update their name, email, phone, address, and cover photo.
      4. Communication preferences -- supporters can opt out of email and/or SMS communications with simple toggle switches.
      "},{"location":"docs/user-guide/profile/#profile-tabs","title":"Profile Tabs","text":"
      • Profile -- edit display name, first/last name, email, phone, and address
      • Preferences -- toggle email and SMS opt-out switches
      • Activity -- paginated timeline of all engagement: emails sent, responses submitted, shift signups, canvass visits, donations, video views, and profile edits
      • Social tabs -- if the viewer is a logged-in user viewing their own profile and the social feature is enabled, additional tabs appear: Friends, Feed, Achievements, Notifications, and Discover
      "},{"location":"docs/user-guide/profile/#security","title":"Security","text":"
      • Token-based access -- no account or login needed; the URL token grants access
      • Password protection -- admins can optionally set a password on the profile link
      • Expiration -- links expire after a configurable duration, showing a branded message with the expiration date
      • Rate limiting -- separate rate limits on profile views, edits, photo uploads, and password attempts
      • Cover photo -- supporters can upload a JPEG, PNG, or WebP cover photo (max 5 MB), automatically resized to 800x400
      "},{"location":"docs/user-guide/profile/#engagement-score","title":"Engagement Score","text":"

      Each profile shows a circular engagement score (0-100) calculated from the contact's activity across the platform -- emails, shifts, canvass visits, donations, and video views.

      "},{"location":"docs/user-guide/profile/#public-routes","title":"Public Routes","text":"
      • /profile/:token -- self-service contact profile page
      "},{"location":"docs/user-guide/shifts/","title":"Shifts","text":"

      Browse available volunteer shifts and sign up to participate in canvassing and other campaign activities.

      "},{"location":"docs/user-guide/shifts/#signing-up","title":"Signing Up","text":"
      1. Visit /shifts to see available time slots
      2. Pick a shift that works for your schedule
      3. Fill in your name and email
      4. You'll receive a confirmation email with login credentials
      "},{"location":"docs/user-guide/shifts/#quick-join","title":"Quick Join","text":"

      Organizers may share a QR code at events for instant onboarding:

      1. Scan the QR code \u2014 it opens a Quick Join page
      2. Enter your email (and optionally your name and phone)
      3. Start immediately \u2014 you're logged in and redirected to the volunteer portal with your area pre-loaded

      Quick Join creates a temporary 24-hour account. Your organizer can upgrade it to a permanent account afterward.

      "},{"location":"docs/user-guide/shifts/#after-signing-up","title":"After Signing Up","text":"

      Once you have an account, log in to access the Volunteer Portal where you can:

      • View your assigned shifts and canvassing areas
      • Open the canvass map for GPS-guided door-to-door outreach
      • Track your activity and visit history

      See the Volunteer Guide for the full volunteer experience.

      "},{"location":"docs/user-guide/shifts/#public-routes","title":"Public Routes","text":"
      • /shifts \u2014 browse and sign up for volunteer shifts
      • /join?token=... \u2014 quick join via invite link or QR code
      "},{"location":"docs/user-guide/shop/","title":"Shop & Pricing","text":"

      Support the campaign by purchasing merchandise or subscribing to a membership plan.

      "},{"location":"docs/user-guide/shop/#shop","title":"Shop","text":"

      Browse available products at /shop:

      • Campaign merchandise and branded items
      • One-time purchases with Stripe checkout
      • Product details with images and descriptions
      "},{"location":"docs/user-guide/shop/#membership-plans","title":"Membership Plans","text":"

      View subscription options at /pricing:

      • Tiered membership plans with different benefits
      • Monthly and yearly billing options
      • Secure recurring payments through Stripe
      "},{"location":"docs/user-guide/shop/#public-routes","title":"Public Routes","text":"
      • /shop \u2014 browse products
      • /pricing \u2014 view subscription plans
      "},{"location":"docs/volunteer/","title":"Volunteer Guide","text":"

      Welcome! This guide walks you through everything you need as a campaign volunteer.

      "},{"location":"docs/volunteer/#getting-started","title":"Getting Started","text":""},{"location":"docs/volunteer/#1-sign-up-for-a-shift","title":"1. Sign Up for a Shift","text":"

      Visit the Shifts page (your organizer will share the link, or find it at /shifts). Browse available time slots, pick one that works, and fill in your name and email. You'll receive a confirmation email with login credentials.

      "},{"location":"docs/volunteer/#2-log-in","title":"2. Log In","text":"

      Sign in with the email and password from your confirmation at the login page.

      "},{"location":"docs/volunteer/#3-explore-the-volunteer-portal","title":"3. Explore the Volunteer Portal","text":"

      After logging in, you'll land on the volunteer portal. Use the bottom navigation to access:

      • Map \u2014 your canvassing area with GPS tracking
      • Shifts \u2014 your upcoming and past shifts
      • Friends \u2014 social connections with other volunteers
      • Achievements \u2014 badges and leaderboards
      "},{"location":"docs/volunteer/#in-this-section","title":"In This Section","text":"
      • Canvassing \u2014 the GPS-guided canvass map, recording visits, and walking routes
      • Shifts \u2014 viewing your assigned shifts, activity log, and route history
      • Social \u2014 friend connections, activity feed, groups, profiles, and privacy settings
      • Achievements \u2014 unlockable badges, progress tracking, and competitive leaderboards
      "},{"location":"docs/volunteer/#browsing-public-pages","title":"Browsing Public Pages","text":"

      Tap your name/avatar in the header and select Browse Site to visit the public pages \u2014 campaigns, the public map, and shift signups.

      "},{"location":"docs/volunteer/#faq","title":"FAQ","text":"

      Q: I can't find my assigned area on the map. A: Make sure your shift has an area assigned. Check with your organizer.

      Q: My GPS isn't working. A: Allow location access in your browser. Try moving near a window for better signal.

      Q: I recorded the wrong outcome. A: Visit the same address again and record the correct outcome. The most recent visit counts.

      Q: How do I sign up for more shifts? A: Visit the public shifts page at /shifts.

      "},{"location":"docs/volunteer/achievements/","title":"Achievements & Leaderboard","text":"

      Recognize volunteer contributions with unlockable achievement badges and competitive leaderboards.

      "},{"location":"docs/volunteer/achievements/#how-it-works","title":"How It Works","text":"

      Achievements are checked automatically when relevant actions occur (e.g., signing up for a shift, completing a canvass session, accepting a friend request). When a user's progress meets the threshold, the badge is unlocked and an in-app notification is sent.

      "},{"location":"docs/volunteer/achievements/#badge-categories","title":"Badge Categories","text":""},{"location":"docs/volunteer/achievements/#shifts","title":"Shifts","text":"Badge Name Threshold Description FIRST_SHIFT First Steps 1 confirmed signup Sign up for your first volunteer shift SHIFT_STREAK_3 Reliable Volunteer 3 confirmed signups Sign up for 3 volunteer shifts SHIFT_STREAK_10 Shift Champion 10 confirmed signups Sign up for 10 volunteer shifts"},{"location":"docs/volunteer/achievements/#canvassing","title":"Canvassing","text":"Badge Name Threshold Description FIRST_CANVASS Door Knocker 1 completed session Complete your first canvass session CANVASS_50_DOORS Neighbourhood Explorer 50 visits Record 50 canvass visits CANVASS_100_DOORS Community Connector 100 visits Record 100 canvass visits CANVASS_500_DOORS Door-to-Door Legend 500 visits Record 500 canvass visits"},{"location":"docs/volunteer/achievements/#campaigns","title":"Campaigns","text":"Badge Name Threshold Description FIRST_CAMPAIGN_EMAIL Voice Heard 1 email sent Send your first advocacy email CAMPAIGN_CHAMPION Campaign Champion 5 distinct campaigns Participate in 5 different campaigns"},{"location":"docs/volunteer/achievements/#social","title":"Social","text":"Badge Name Threshold Description SOCIAL_BUTTERFLY Social Butterfly 10 accepted friends Make 10 friends on the platform TEAM_PLAYER Team Player 3 group memberships Be a member of 3 groups"},{"location":"docs/volunteer/achievements/#progress-tracking","title":"Progress Tracking","text":"

      Each badge displays a progress bar showing current progress toward the threshold. Already-unlocked badges show the unlock date and the progress value at unlock time or the current count (whichever is higher).

      "},{"location":"docs/volunteer/achievements/#leaderboards","title":"Leaderboards","text":"

      The Achievements page includes a leaderboard tab with three ranking types:

      • Canvass \u2014 ranked by total canvass visits recorded
      • Shifts \u2014 ranked by total confirmed shift signups
      • Campaigns \u2014 ranked by number of distinct campaigns participated in

      Leaderboard entries show rank, user name, and score. Users who have disabled \"Show in Friend Activity\" in their privacy settings are excluded from leaderboard rankings to respect their privacy choices.

      "},{"location":"docs/volunteer/achievements/#volunteer-stats","title":"Volunteer Stats","text":"

      The Achievements page also displays aggregate stats for the current user:

      • Confirmed shift signups
      • Completed canvass sessions
      • Total canvass visits
      • Advocacy emails sent
      • Campaigns participated in
      • Friend count
      • Group memberships
      "},{"location":"docs/volunteer/achievements/#volunteer-routes","title":"Volunteer Routes","text":"
      • /volunteer/achievements \u2014 badge gallery, progress bars, leaderboard tabs, and personal stats
      "},{"location":"docs/volunteer/canvassing/","title":"Canvassing","text":"

      The volunteer canvass map is your main tool for door-to-door outreach \u2014 a full-screen GPS-tracked experience.

      "},{"location":"docs/volunteer/canvassing/#the-volunteer-map","title":"The Volunteer Map","text":""},{"location":"docs/volunteer/canvassing/#what-you-see","title":"What You See","text":"
      • Colored markers \u2014 each marker is an address. Colors indicate the outcome of the last visit (green = supportive, red = opposed, grey = not yet visited)
      • Clusters \u2014 when zoomed out, markers group together showing the address count. Tap a cluster to zoom in.
      • Blue dot \u2014 your current GPS position
      • Walking route \u2014 a suggested path through the addresses (dotted line)
      "},{"location":"docs/volunteer/canvassing/#recording-a-visit","title":"Recording a Visit","text":"
      1. Tap a marker to select an address
      2. A bottom panel slides up showing address details
      3. Tap Record Visit to log what happened:
        • Not Home \u2014 nobody answered
        • Supportive \u2014 positive interaction
        • Opposed \u2014 not supportive
        • Undecided \u2014 hasn't made up their mind
        • Moved \u2014 no longer lives there
        • Refused \u2014 declined to talk
      4. Optionally add a note about the visit
      5. Tap Save \u2014 the marker color updates immediately
      "},{"location":"docs/volunteer/canvassing/#sessions","title":"Sessions","text":"
      • Start a session before you begin knocking on doors \u2014 this tracks your route and time
      • End your session when you're done for the day
      • The map works offline for basic viewing, but you need a connection to save visits
      • If GPS is inaccurate, manually tap the correct marker on the map
      "},{"location":"docs/volunteer/canvassing/#routes","title":"Routes","text":"

      The Routes tab shows your past canvassing routes on a map, helping you see which areas you've covered and plan your next outing.

      "},{"location":"docs/volunteer/canvassing/#volunteer-routes","title":"Volunteer Routes","text":"
      • /volunteer \u2014 full-screen canvass map with GPS and visit recording
      • /volunteer/activity \u2014 visit history and outcome breakdown
      • /volunteer/routes \u2014 past canvassing routes
      "},{"location":"docs/volunteer/shifts/","title":"Your Shifts","text":"

      View your upcoming and past volunteer shifts from the Shifts tab in the bottom navigation.

      "},{"location":"docs/volunteer/shifts/#shift-details","title":"Shift Details","text":"

      Each shift shows:

      • Date and time
      • Assigned area (if linked to a canvassing territory)
      • A button to open the canvass map for that area
      "},{"location":"docs/volunteer/shifts/#activity-log","title":"Activity Log","text":"

      The Activity tab shows your complete visit history:

      • Outcome breakdown \u2014 pie chart of your visit outcomes
      • Visit list \u2014 each visit with address, outcome, time, and notes
      • Stats \u2014 total visits, addresses covered, and sessions completed
      "},{"location":"docs/volunteer/shifts/#volunteer-routes","title":"Volunteer Routes","text":"
      • /volunteer/shifts \u2014 view assigned shifts
      • /volunteer/activity \u2014 visit history and outcome breakdown
      "},{"location":"docs/volunteer/social/","title":"Social Connections","text":"

      Connect with fellow volunteers through friend requests, activity feeds, team groups, and real-time notifications. Enable via Settings > Feature Toggles > Social Connections.

      "},{"location":"docs/volunteer/social/#friends","title":"Friends","text":"
      • Send requests \u2014 search for other volunteers and send friend requests
      • Accept / decline / cancel \u2014 manage requests from the Friends page
      • Mutual friends \u2014 view shared connections between users
      • Block / unblock \u2014 blocked users cannot send requests or appear in suggestions
      "},{"location":"docs/volunteer/social/#discover","title":"Discover","text":"

      The Discover page suggests potential friends using a ranked scoring algorithm based on:

      • Household/family connections (highest priority)
      • Mutual friends
      • Shared shifts (co-volunteers from the last 90 days)
      • Shared campaigns (co-participants from the last 90 days)
      "},{"location":"docs/volunteer/social/#activity-feed","title":"Activity Feed","text":"

      The Social Feed at /volunteer/feed shows recent activity from your friends:

      • Shift signups, campaign emails, canvass sessions, and response submissions
      • Limited to the last 30 days (max 50 items)
      "},{"location":"docs/volunteer/social/#groups","title":"Groups","text":"

      Groups are automatically created based on platform activity:

      • Shift teams \u2014 created when 2+ volunteers share a shift
      • Campaign teams \u2014 created when 2+ users participate in the same campaign
      "},{"location":"docs/volunteer/social/#profiles","title":"Profiles","text":"

      Each volunteer has a social profile showing volunteer stats, achievement badges, friendship status, and recent activity.

      "},{"location":"docs/volunteer/social/#pokes","title":"Pokes","text":"

      Send a friendly nudge to any accepted friend (24-hour cooldown per pair).

      "},{"location":"docs/volunteer/social/#privacy-settings","title":"Privacy Settings","text":"Setting Default Description Show online status On Whether friends see you as online Show in friend activity On Whether your actions appear in feeds Allow friend requests On Whether others can send you requests"},{"location":"docs/volunteer/social/#digest-emails","title":"Digest Emails","text":"

      Opt into periodic social digest emails with friend activity, unread notifications, and pending requests. Choose daily or weekly frequency.

      "},{"location":"docs/volunteer/social/#volunteer-routes","title":"Volunteer Routes","text":"
      • /volunteer/feed \u2014 social activity feed
      • /volunteer/friends \u2014 friends, requests, blocked, and groups
      • /volunteer/discover \u2014 ranked friend suggestions
      • /volunteer/profile \u2014 your social profile
      • /volunteer/profile/:userId \u2014 another volunteer's profile
      • /volunteer/notifications \u2014 notification center and preferences
      • /volunteer/groups/:id \u2014 group detail with member list
      "}]} \ No newline at end of file diff --git a/mkdocs/site/sitemap.xml b/mkdocs/site/sitemap.xml index e58e5434..a37142cc 100644 --- a/mkdocs/site/sitemap.xml +++ b/mkdocs/site/sitemap.xml @@ -2,70 +2,298 @@ https://bnkserve.org/ - 2026-02-18 + 2026-02-26 https://bnkserve.org/lander/ - 2026-02-18 + 2026-02-26 https://bnkserve.org/main/ - 2026-02-18 + 2026-02-26 https://bnkserve.org/test/ - 2026-02-18 + 2026-02-26 https://bnkserve.org/blog/ - 2026-02-18 + 2026-02-26 + + + https://bnkserve.org/comments/callback/ + 2026-02-26 https://bnkserve.org/docs/ - 2026-02-18 + 2026-02-26 + + + https://bnkserve.org/docs/phil/ + 2026-02-26 https://bnkserve.org/docs/admin/ - 2026-02-18 + 2026-02-26 + + + https://bnkserve.org/docs/admin/dashboard/ + 2026-02-26 + + + https://bnkserve.org/docs/admin/people-access/ + 2026-02-26 + + + https://bnkserve.org/docs/admin/settings/ + 2026-02-26 + + + https://bnkserve.org/docs/admin/advocacy/ + 2026-02-26 + + + https://bnkserve.org/docs/admin/advocacy/campaigns/ + 2026-02-26 + + + https://bnkserve.org/docs/admin/advocacy/email-queue/ + 2026-02-26 + + + https://bnkserve.org/docs/admin/advocacy/representatives/ + 2026-02-26 + + + https://bnkserve.org/docs/admin/advocacy/responses/ + 2026-02-26 + + + https://bnkserve.org/docs/admin/broadcast/ + 2026-02-26 + + + https://bnkserve.org/docs/admin/broadcast/email-templates/ + 2026-02-26 + + + https://bnkserve.org/docs/admin/broadcast/newsletter/ + 2026-02-26 + + + https://bnkserve.org/docs/admin/broadcast/sms/ + 2026-02-26 + + + https://bnkserve.org/docs/admin/map/ + 2026-02-26 + + + https://bnkserve.org/docs/admin/map/areas/ + 2026-02-26 + + + https://bnkserve.org/docs/admin/map/canvassing/ + 2026-02-26 + + + https://bnkserve.org/docs/admin/map/data-quality/ + 2026-02-26 + + + https://bnkserve.org/docs/admin/map/locations/ + 2026-02-26 + + + https://bnkserve.org/docs/admin/map/settings/ + 2026-02-26 + + + https://bnkserve.org/docs/admin/map/shifts/ + 2026-02-26 + + + https://bnkserve.org/docs/admin/media/ + 2026-02-26 + + + https://bnkserve.org/docs/admin/media/ads/ + 2026-02-26 + + + https://bnkserve.org/docs/admin/media/analytics/ + 2026-02-26 + + + https://bnkserve.org/docs/admin/media/curated/ + 2026-02-26 + + + https://bnkserve.org/docs/admin/media/library/ + 2026-02-26 + + + https://bnkserve.org/docs/admin/media/moderation/ + 2026-02-26 + + + https://bnkserve.org/docs/admin/payments/ + 2026-02-26 + + + https://bnkserve.org/docs/admin/payments/donations/ + 2026-02-26 + + + https://bnkserve.org/docs/admin/payments/plans/ + 2026-02-26 + + + https://bnkserve.org/docs/admin/payments/products/ + 2026-02-26 + + + https://bnkserve.org/docs/admin/payments/settings/ + 2026-02-26 + + + https://bnkserve.org/docs/admin/services/ + 2026-02-26 + + + https://bnkserve.org/docs/admin/services/integrations/ + 2026-02-26 + + + https://bnkserve.org/docs/admin/services/monitoring/ + 2026-02-26 + + + https://bnkserve.org/docs/admin/services/tunnel/ + 2026-02-26 + + + https://bnkserve.org/docs/admin/services/user-provisioning/ + 2026-02-26 + + + https://bnkserve.org/docs/admin/web/ + 2026-02-26 + + + https://bnkserve.org/docs/admin/web/documentation/ + 2026-02-26 + + + https://bnkserve.org/docs/admin/web/homepage/ + 2026-02-26 + + + https://bnkserve.org/docs/admin/web/landing-pages/ + 2026-02-26 + + + https://bnkserve.org/docs/admin/web/navigation/ + 2026-02-26 https://bnkserve.org/docs/api/ - 2026-02-18 + 2026-02-26 https://bnkserve.org/docs/architecture/ - 2026-02-18 + 2026-02-26 https://bnkserve.org/docs/deployment/ - 2026-02-18 - - - https://bnkserve.org/docs/features/ - 2026-02-18 + 2026-02-26 https://bnkserve.org/docs/getting-started/ - 2026-02-18 + 2026-02-26 https://bnkserve.org/docs/getting-started/environment-variables/ - 2026-02-18 + 2026-02-26 + + + https://bnkserve.org/docs/getting-started/features/ + 2026-02-26 + + + https://bnkserve.org/docs/getting-started/first-steps/ + 2026-02-26 + + + https://bnkserve.org/docs/getting-started/installation/ + 2026-02-26 https://bnkserve.org/docs/services/ - 2026-02-18 + 2026-02-26 https://bnkserve.org/docs/troubleshooting/ - 2026-02-18 + 2026-02-26 + + + https://bnkserve.org/docs/user-guide/ + 2026-02-26 + + + https://bnkserve.org/docs/user-guide/campaigns/ + 2026-02-26 + + + https://bnkserve.org/docs/user-guide/donations/ + 2026-02-26 + + + https://bnkserve.org/docs/user-guide/events/ + 2026-02-26 + + + https://bnkserve.org/docs/user-guide/gallery/ + 2026-02-26 + + + https://bnkserve.org/docs/user-guide/map/ + 2026-02-26 + + + https://bnkserve.org/docs/user-guide/profile/ + 2026-02-26 + + + https://bnkserve.org/docs/user-guide/shifts/ + 2026-02-26 + + + https://bnkserve.org/docs/user-guide/shop/ + 2026-02-26 https://bnkserve.org/docs/volunteer/ - 2026-02-18 + 2026-02-26 + + + https://bnkserve.org/docs/volunteer/achievements/ + 2026-02-26 + + + https://bnkserve.org/docs/volunteer/canvassing/ + 2026-02-26 + + + https://bnkserve.org/docs/volunteer/shifts/ + 2026-02-26 + + + https://bnkserve.org/docs/volunteer/social/ + 2026-02-26 https://bnkserve.org/partials/integrations/analytics/custom/ - 2026-02-18 + 2026-02-26 \ No newline at end of file diff --git a/mkdocs/site/sitemap.xml.gz b/mkdocs/site/sitemap.xml.gz index cd4f2c5410fb88e6ceb2b7acbc74b1b6af2869e2..5cf19c007d9b0dd7ccec27b080eca2d27771cd33 100644 GIT binary patch literal 717 zcmV;;0y6y{iwFn+jh|@(|8r?{Wo=<_E_iKh0L7WXZrd;nhVOlfAa}=U4#hU4>1|K2 zJpe7SwoqC0MA=#1eza30UeUv_1DQ)~N8tBcqDYE4zWnavX2Q&Y()0Flx8H6sIr1U3 z&)c6rzE@wiFQe=N*TFJq)~~+}7+mx#9Z#VZGmd-JTvc z@|g`>ua?g-k~_^-Sj;r(jWJKSm^jw+PwSDur6+$n><>@X{!ksBWXSuotLge?`=2Qh zlEmej1R6#CEs3wxuYB}nAOoeL)F*e}dl(?Q8YodlOL`@#}Ud=OCKo8tC1ljAO z2c#il1bq)T`peeU3e`PZ1e%Fu;9(!7!t%w3C>J_8?$5>Z-tMc z$&fErc(I0g+3f~3KU}V-M?12oWEJXe8l=LpPJW8{TB} zltQ6wQuo|MlrbfYh5|;ps$@q@VeY${OvV`-8>rll{q^u`kq%}F9rZXsi#GJR`7BFb z%?j8?LV_u@`U@}wRa|ymDCo+VRae^M5NSy{n$-51+$t`Rl?%;}XiQVcl;#y>C$gl}zL8dL2x~J_GUAdjQB1=X48lsiU>-W_Df|Kg3PZUQv(dqtMaEF4FRXmM zxVnU~M7>#ca{BCLV^K_JWSN|BDd(IJOE4tSEBYleh*g(4r`c&w%4O=9H0MH|pLo>% b$}aE6EW{Z8zL{H>Yya>CN?oi`D+mAp25h1K diff --git a/mkdocs/site/stylesheets/docs-comments.css b/mkdocs/site/stylesheets/docs-comments.css new file mode 100644 index 00000000..1f025220 --- /dev/null +++ b/mkdocs/site/stylesheets/docs-comments.css @@ -0,0 +1,301 @@ +/* ========================================================================== + Docs Comments Widget — MkDocs Material Theme Integration + Uses Material CSS custom properties for automatic dark/light theme support + ========================================================================== */ + +.dc-widget { + margin-top: 2rem; + padding-top: 1.5rem; + border-top: 1px solid var(--md-default-fg-color--lightest, rgba(0,0,0,0.07)); +} + +.dc-title { + font-size: 1.25rem; + font-weight: 600; + margin: 0 0 1rem; + color: var(--md-default-fg-color, inherit); +} + +/* --- Comment List --- */ + +.dc-comments-list { + margin-bottom: 1.5rem; +} + +.dc-comments-list.dc-loading { + color: var(--md-default-fg-color--light, #666); + font-style: italic; +} + +.dc-empty { + color: var(--md-default-fg-color--light, #666); + font-style: italic; + margin: 0.5rem 0; +} + +.dc-error { + color: var(--md-accent-fg-color, #e53935); + font-style: italic; +} + +/* --- Individual Comment --- */ + +.dc-comment { + padding: 0.75rem 1rem; + margin-bottom: 0.75rem; + border-radius: 8px; + background: var(--md-code-bg-color, rgba(0,0,0,0.04)); + border: 1px solid var(--md-default-fg-color--lightest, rgba(0,0,0,0.07)); +} + +.dc-comment__header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; + flex-wrap: wrap; +} + +.dc-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; +} + +.dc-avatar--initials { + display: inline-flex; + align-items: center; + justify-content: center; + background: var(--md-primary-fg-color, #6f42c1); + color: #fff; + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.02em; +} + +.dc-comment__author { + font-weight: 600; + font-size: 0.875rem; + color: var(--md-default-fg-color, inherit); +} + +.dc-comment__badge { + font-size: 0.65rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 1px 6px; + border-radius: 4px; + background: var(--md-default-fg-color--lightest, rgba(0,0,0,0.07)); + color: var(--md-default-fg-color--light, #666); +} + +.dc-comment__time { + font-size: 0.75rem; + color: var(--md-default-fg-color--light, #999); + margin-left: auto; +} + +.dc-comment__body { + font-size: 0.9rem; + line-height: 1.6; + color: var(--md-default-fg-color, inherit); + word-break: break-word; +} + +.dc-comment__body a { + color: var(--md-accent-fg-color, var(--md-primary-fg-color)); + text-decoration: underline; +} + +.dc-inline-code { + font-family: var(--md-code-font-family, 'JetBrains Mono', monospace); + font-size: 0.85em; + padding: 0.1em 0.3em; + border-radius: 3px; + background: var(--md-code-bg-color, rgba(0,0,0,0.04)); +} + +/* --- Form --- */ + +.dc-form { + padding: 1rem; + border-radius: 8px; + background: var(--md-code-bg-color, rgba(0,0,0,0.04)); + border: 1px solid var(--md-default-fg-color--lightest, rgba(0,0,0,0.07)); +} + +.dc-form__row { + display: flex; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + +.dc-form__user { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.75rem; + font-size: 0.875rem; + color: var(--md-default-fg-color, inherit); +} + +.dc-input, +.dc-textarea { + width: 100%; + padding: 0.5rem 0.75rem; + font-family: inherit; + font-size: 0.875rem; + border: 1px solid var(--md-default-fg-color--lightest, rgba(0,0,0,0.12)); + border-radius: 6px; + background: var(--md-default-bg-color, #fff); + color: var(--md-default-fg-color, inherit); + transition: border-color 0.15s; + box-sizing: border-box; +} + +.dc-input:focus, +.dc-textarea:focus { + outline: none; + border-color: var(--md-primary-fg-color, #6f42c1); + box-shadow: 0 0 0 2px rgba(111, 66, 193, 0.15); +} + +.dc-textarea { + resize: vertical; + min-height: 80px; + margin-bottom: 0.5rem; +} + +.dc-form__actions { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.dc-form__note { + font-size: 0.75rem; + color: var(--md-default-fg-color--light, #999); + margin: 0.5rem 0 0; + font-style: italic; +} + +/* Honeypot — visually hidden */ +.dc-honeypot { + position: absolute !important; + width: 1px !important; + height: 1px !important; + padding: 0 !important; + margin: -1px !important; + overflow: hidden !important; + clip: rect(0, 0, 0, 0) !important; + white-space: nowrap !important; + border: 0 !important; +} + +/* --- Buttons --- */ + +.dc-btn { + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0.5rem 1rem; + font-family: inherit; + font-size: 0.8rem; + font-weight: 600; + border-radius: 6px; + border: none; + cursor: pointer; + transition: background 0.15s, opacity 0.15s; + text-decoration: none; + white-space: nowrap; +} + +.dc-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.dc-btn--primary { + background: var(--md-primary-fg-color, #6f42c1); + color: var(--md-primary-bg-color, #fff); +} + +.dc-btn--primary:hover:not(:disabled) { + opacity: 0.9; +} + +.dc-btn--outline { + background: transparent; + border: 1px solid var(--md-primary-fg-color, #6f42c1); + color: var(--md-primary-fg-color, #6f42c1); +} + +.dc-btn--outline:hover:not(:disabled) { + background: rgba(111, 66, 193, 0.08); +} + +.dc-btn--text { + background: transparent; + color: var(--md-default-fg-color--light, #999); + padding: 0.25rem 0.5rem; + font-size: 0.75rem; +} + +.dc-btn--text:hover { + color: var(--md-default-fg-color, inherit); +} + +/* --- Messages --- */ + +.dc-form-message { + font-size: 0.8rem; + padding: 0.5rem 0.75rem; + border-radius: 6px; + margin-top: 0.5rem; + animation: dc-fade-in 0.2s ease; +} + +.dc-form-message--error { + background: rgba(229, 57, 53, 0.1); + color: #e53935; + border: 1px solid rgba(229, 57, 53, 0.2); +} + +.dc-form-message--success { + background: rgba(76, 175, 80, 0.1); + color: #4caf50; + border: 1px solid rgba(76, 175, 80, 0.2); +} + +@keyframes dc-fade-in { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} + +/* --- Responsive --- */ + +@media (max-width: 600px) { + .dc-form__row { + flex-direction: column; + } + + .dc-comment__time { + margin-left: 0; + width: 100%; + order: 10; + margin-top: 0.25rem; + } + + .dc-form__actions { + flex-direction: column; + align-items: stretch; + } + + .dc-btn { + justify-content: center; + } +} diff --git a/mkdocs/site/test/index.html b/mkdocs/site/test/index.html index 49efdb56..b8524e0a 100644 --- a/mkdocs/site/test/index.html +++ b/mkdocs/site/test/index.html @@ -22,7 +22,7 @@ - + @@ -56,8 +56,12 @@ + + + + @@ -175,43 +179,63 @@ - + + +
      @@ -606,6 +742,8 @@ + + @@ -775,6 +913,29 @@

      Test

      Testing page.

      +
      Loading...
      + + +
      🛒
      DIGITAL
      @@ -1055,8 +1216,16 @@ Donate + + + + + + + + \ No newline at end of file diff --git a/nginx/conf.d/default.conf b/nginx/conf.d/default.conf index add1afca..276d6a72 100644 --- a/nginx/conf.d/default.conf +++ b/nginx/conf.d/default.conf @@ -5,6 +5,55 @@ server { server_name localhost _; add_header X-Frame-Options "SAMEORIGIN" always; + # Social media bot detection for OG meta tags + set $is_bot 0; + if ($http_user_agent ~* "(Twitterbot|facebookexternalhit|LinkedInBot|Slackbot|TelegramBot|WhatsApp|Discordbot|Googlebot|bingbot|Pinterest)") { + set $is_bot 1; + } + + # Bot-specific rewrites for rich social previews + location ~ ^/campaign/([^/]+)$ { + if ($is_bot) { + rewrite ^/campaign/(.+)$ /api/og/campaign/$1 last; + } + set $upstream_admin http://changemaker-v2-admin:3000; + proxy_pass $upstream_admin; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + location ~ ^/p/([^/]+)$ { + if ($is_bot) { + rewrite ^/p/(.+)$ /api/og/page/$1 last; + } + set $upstream_admin http://changemaker-v2-admin:3000; + proxy_pass $upstream_admin; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + location ~ ^/gallery/watch/([^/]+)$ { + if ($is_bot) { + rewrite ^/gallery/watch/(.+)$ /api/og/gallery/$1 last; + } + set $upstream_admin http://changemaker-v2-admin:3000; + proxy_pass $upstream_admin; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + # Admin GUI (default) location / { set $upstream_admin http://changemaker-v2-admin:3000; diff --git a/nginx/conf.d/default.conf.template b/nginx/conf.d/default.conf.template index d9a17c18..2371e9f3 100644 --- a/nginx/conf.d/default.conf.template +++ b/nginx/conf.d/default.conf.template @@ -5,6 +5,55 @@ server { server_name localhost _; add_header X-Frame-Options "SAMEORIGIN" always; + # Social media bot detection for OG meta tags + set $is_bot 0; + if ($http_user_agent ~* "(Twitterbot|facebookexternalhit|LinkedInBot|Slackbot|TelegramBot|WhatsApp|Discordbot|Googlebot|bingbot|Pinterest)") { + set $is_bot 1; + } + + # Bot-specific rewrites for rich social previews + location ~ ^/campaign/([^/]+)$ { + if ($is_bot) { + rewrite ^/campaign/(.+)$ /api/og/campaign/$1 last; + } + set $upstream_admin http://changemaker-v2-admin:3000; + proxy_pass $upstream_admin; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + location ~ ^/p/([^/]+)$ { + if ($is_bot) { + rewrite ^/p/(.+)$ /api/og/page/$1 last; + } + set $upstream_admin http://changemaker-v2-admin:3000; + proxy_pass $upstream_admin; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + location ~ ^/gallery/watch/([^/]+)$ { + if ($is_bot) { + rewrite ^/gallery/watch/(.+)$ /api/og/gallery/$1 last; + } + set $upstream_admin http://changemaker-v2-admin:3000; + proxy_pass $upstream_admin; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + # Admin GUI (default) location / { set $upstream_admin http://changemaker-v2-admin:3000; diff --git a/nginx/conf.d/services.conf b/nginx/conf.d/services.conf index 31687ade..0bab3390 100644 --- a/nginx/conf.d/services.conf +++ b/nginx/conf.d/services.conf @@ -37,15 +37,16 @@ server { } } -# Grafana +# Grafana — allows iframe embedding from admin (app.cmlite.org) server { listen 80; server_name grafana.cmlite.org grafana.betteredmonton.org; - add_header X-Frame-Options "SAMEORIGIN" always; + add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org app.betteredmonton.org" always; location / { set $upstream_grafana http://grafana-changemaker:3000; proxy_pass $upstream_grafana; + proxy_hide_header X-Frame-Options; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; @@ -242,6 +243,26 @@ server { } } +# Jitsi Meet (video conferencing) — allows iframe embedding from admin (app.cmlite.org) +server { + listen 80; + server_name meet.cmlite.org meet.betteredmonton.org; + add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org app.betteredmonton.org" always; + + location / { + set $upstream_jitsi http://jitsi-web-changemaker:80; + proxy_pass $upstream_jitsi; + proxy_hide_header X-Frame-Options; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_http_version 1.1; + } +} + # --- Embed proxy ports (for iframe embedding without DNS/subdomain) --- # These listen on dedicated ports so the admin GUI can iframe services via # localhost:PORT, bypassing X-Frame-Options without needing *.localhost DNS. @@ -348,6 +369,55 @@ server { server_name app.cmlite.org app.betteredmonton.org; add_header X-Frame-Options "SAMEORIGIN" always; + # Social media bot detection for OG meta tags + set $is_bot 0; + if ($http_user_agent ~* "(Twitterbot|facebookexternalhit|LinkedInBot|Slackbot|TelegramBot|WhatsApp|Discordbot|Googlebot|bingbot|Pinterest|Embedly|Quora Link Preview|Showyoubot|outbrain|vkShare|W3C_Validator)") { + set $is_bot 1; + } + + # Bot-specific rewrites — serve OG meta from API for rich social previews + location ~ ^/campaign/([^/]+)$ { + if ($is_bot) { + rewrite ^/campaign/(.+)$ /api/og/campaign/$1 last; + } + set $upstream_admin http://changemaker-v2-admin:3000; + proxy_pass $upstream_admin; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + location ~ ^/p/([^/]+)$ { + if ($is_bot) { + rewrite ^/p/(.+)$ /api/og/page/$1 last; + } + set $upstream_admin http://changemaker-v2-admin:3000; + proxy_pass $upstream_admin; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + location ~ ^/gallery/watch/([^/]+)$ { + if ($is_bot) { + rewrite ^/gallery/watch/(.+)$ /api/og/gallery/$1 last; + } + set $upstream_admin http://changemaker-v2-admin:3000; + proxy_pass $upstream_admin; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + location / { set $upstream_admin http://changemaker-v2-admin:3000; proxy_pass $upstream_admin; @@ -594,3 +664,53 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } } + +# Jitsi Meet embed proxy (port 8893) +server { + listen 8893; + location / { + set $upstream_jitsi http://jitsi-web-changemaker:80; + proxy_pass $upstream_jitsi; + proxy_hide_header X-Frame-Options; + proxy_hide_header Content-Security-Policy; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_http_version 1.1; + } +} + +# Grafana embed proxy (port 8894) +server { + listen 8894; + location / { + set $upstream_grafana http://grafana-changemaker:3000; + proxy_pass $upstream_grafana; + proxy_hide_header X-Frame-Options; + proxy_hide_header Content-Security-Policy; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} + +# Alertmanager embed proxy (port 8895) +server { + listen 8895; + location / { + set $upstream_alertmanager http://alertmanager-changemaker:9093; + proxy_pass $upstream_alertmanager; + proxy_hide_header X-Frame-Options; + proxy_hide_header Content-Security-Policy; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/nginx/conf.d/services.conf.template b/nginx/conf.d/services.conf.template index a36fe1c0..72784d08 100644 --- a/nginx/conf.d/services.conf.template +++ b/nginx/conf.d/services.conf.template @@ -37,15 +37,16 @@ server { } } -# Grafana +# Grafana — allows iframe embedding from admin (app.${DOMAIN}) server { listen 80; server_name grafana.${DOMAIN}; - add_header X-Frame-Options "SAMEORIGIN" always; + add_header Content-Security-Policy "frame-ancestors 'self' app.${DOMAIN}" always; location / { set $upstream_grafana http://grafana-changemaker:3000; proxy_pass $upstream_grafana; + proxy_hide_header X-Frame-Options; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; @@ -242,6 +243,26 @@ server { } } +# Jitsi Meet (video conferencing) — allows iframe embedding from admin (app.${DOMAIN}) +server { + listen 80; + server_name meet.${DOMAIN}; + add_header Content-Security-Policy "frame-ancestors 'self' app.${DOMAIN}" always; + + location / { + set $upstream_jitsi http://jitsi-web-changemaker:80; + proxy_pass $upstream_jitsi; + proxy_hide_header X-Frame-Options; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_http_version 1.1; + } +} + # --- Embed proxy ports (for iframe embedding without DNS/subdomain) --- # These listen on dedicated ports so the admin GUI can iframe services via # localhost:PORT, bypassing X-Frame-Options without needing *.localhost DNS. @@ -348,6 +369,55 @@ server { server_name app.${DOMAIN}; add_header X-Frame-Options "SAMEORIGIN" always; + # Social media bot detection for OG meta tags + set $is_bot 0; + if ($http_user_agent ~* "(Twitterbot|facebookexternalhit|LinkedInBot|Slackbot|TelegramBot|WhatsApp|Discordbot|Googlebot|bingbot|Pinterest)") { + set $is_bot 1; + } + + # Bot-specific rewrites for rich social previews + location ~ ^/campaign/([^/]+)$ { + if ($is_bot) { + rewrite ^/campaign/(.+)$ /api/og/campaign/$1 last; + } + set $upstream_admin http://changemaker-v2-admin:3000; + proxy_pass $upstream_admin; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + location ~ ^/p/([^/]+)$ { + if ($is_bot) { + rewrite ^/p/(.+)$ /api/og/page/$1 last; + } + set $upstream_admin http://changemaker-v2-admin:3000; + proxy_pass $upstream_admin; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + + location ~ ^/gallery/watch/([^/]+)$ { + if ($is_bot) { + rewrite ^/gallery/watch/(.+)$ /api/og/gallery/$1 last; + } + set $upstream_admin http://changemaker-v2-admin:3000; + proxy_pass $upstream_admin; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } + location / { set $upstream_admin http://changemaker-v2-admin:3000; proxy_pass $upstream_admin; @@ -510,3 +580,53 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } } + +# Jitsi Meet embed proxy (port 8893) +server { + listen 8893; + location / { + set $upstream_jitsi http://jitsi-web-changemaker:80; + proxy_pass $upstream_jitsi; + proxy_hide_header X-Frame-Options; + proxy_hide_header Content-Security-Policy; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_http_version 1.1; + } +} + +# Grafana embed proxy (port 8894) +server { + listen 8894; + location / { + set $upstream_grafana http://grafana-changemaker:3000; + proxy_pass $upstream_grafana; + proxy_hide_header X-Frame-Options; + proxy_hide_header Content-Security-Policy; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} + +# Alertmanager embed proxy (port 8895) +server { + listen 8895; + location / { + set $upstream_alertmanager http://alertmanager-changemaker:9093; + proxy_pass $upstream_alertmanager; + proxy_hide_header X-Frame-Options; + proxy_hide_header Content-Security-Policy; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/scripts/mkdocs-build-trigger.py b/scripts/mkdocs-build-trigger.py new file mode 100644 index 00000000..d99e3fa2 --- /dev/null +++ b/scripts/mkdocs-build-trigger.py @@ -0,0 +1,81 @@ +""" +Tiny HTTP server that triggers `mkdocs build --clean` on POST /build. +Runs inside the MkDocs container alongside `mkdocs serve`. +Listens on port 8001 (internal only, not exposed to host). +""" + +import subprocess +import json +import time +from http.server import HTTPServer, BaseHTTPRequestHandler + + +class BuildHandler(BaseHTTPRequestHandler): + def do_POST(self): + if self.path != '/build': + self.send_response(404) + self.end_headers() + self.wfile.write(b'{"error":"not found"}') + return + + start = time.time() + try: + result = subprocess.run( + ['mkdocs', 'build', '--clean'], + cwd='/docs', + capture_output=True, + text=True, + timeout=120, + ) + duration = int((time.time() - start) * 1000) + body = json.dumps({ + 'success': result.returncode == 0, + 'output': (result.stdout + result.stderr) or '(no output)', + 'duration': duration, + }) + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.end_headers() + self.wfile.write(body.encode()) + except subprocess.TimeoutExpired: + duration = int((time.time() - start) * 1000) + body = json.dumps({ + 'success': False, + 'output': 'Build timed out after 120s', + 'duration': duration, + }) + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.end_headers() + self.wfile.write(body.encode()) + except Exception as e: + duration = int((time.time() - start) * 1000) + body = json.dumps({ + 'success': False, + 'output': f'Build error: {str(e)}', + 'duration': duration, + }) + self.send_response(500) + self.send_header('Content-Type', 'application/json') + self.end_headers() + self.wfile.write(body.encode()) + + def do_GET(self): + if self.path == '/health': + self.send_response(200) + self.send_header('Content-Type', 'application/json') + self.end_headers() + self.wfile.write(b'{"status":"ok"}') + return + self.send_response(404) + self.end_headers() + + def log_message(self, format, *args): + # Suppress default logging to keep container logs clean + pass + + +if __name__ == '__main__': + server = HTTPServer(('0.0.0.0', 8001), BuildHandler) + print('Build trigger server listening on :8001') + server.serve_forever() diff --git a/scripts/mkdocs-entrypoint.sh b/scripts/mkdocs-entrypoint.sh new file mode 100755 index 00000000..f3ae3278 --- /dev/null +++ b/scripts/mkdocs-entrypoint.sh @@ -0,0 +1,13 @@ +#!/bin/sh +# Entrypoint for MkDocs container: runs both the dev server and the build trigger. + +# Start the build trigger server in the background +python /scripts/mkdocs-build-trigger.py & +BUILD_PID=$! + +# Start MkDocs serve — restart it on crash so the build trigger stays alive +while true; do + mkdocs serve --dev-addr=0.0.0.0:8000 --watch-theme --livereload || true + echo "MkDocs serve exited, restarting in 5s..." + sleep 5 +done diff --git a/scripts/upgrade.sh b/scripts/upgrade.sh index 59fd58b0..d4633fab 100755 --- a/scripts/upgrade.sh +++ b/scripts/upgrade.sh @@ -31,6 +31,7 @@ INFRA_CONTAINERS="v2-postgres redis" USER_PATHS=( "mkdocs/docs/" "mkdocs/mkdocs.yml" + "mkdocs/site/" "configs/" "nginx/conf.d/services.conf" ) @@ -143,7 +144,7 @@ on_failure() { local exit_code=$? release_lock if [[ $exit_code -ne 0 ]] && [[ "$DRY_RUN" != "true" ]]; then - error "Upgrade failed at line $BASH_LINENO (exit code $exit_code)" + error "Upgrade failed at line ${BASH_LINENO[0]} (exit code $exit_code)" print_rollback_help info "Log file: $LOG_FILE" fi @@ -266,7 +267,7 @@ if [[ "$ROLLBACK" == "true" ]]; then exit 0 fi - git checkout "$ROLLBACK_COMMIT" + git checkout -B "$BRANCH" "$ROLLBACK_COMMIT" docker compose build $SOURCE_CONTAINERS docker compose up -d success "Rolled back to $ROLLBACK_COMMIT" @@ -458,18 +459,13 @@ if [[ "$DRY_RUN" == "true" ]]; then exit 0 fi -# Step 1: Reset generated files (regenerated on container start) -info "Resetting generated files..." -git checkout -- mkdocs/site/ 2>/dev/null || true -success "Reset mkdocs/site/ (regenerated on start)" - -# Step 2: Stash user changes if any exist +# Step 1: Stash user changes if any exist HAS_CHANGES=false if [[ -n "$(git status --porcelain 2>/dev/null)" ]]; then HAS_CHANGES=true STASH_NAME="upgrade-${TIMESTAMP}" info "Stashing local changes as '$STASH_NAME'..." - git stash push -m "$STASH_NAME" --include-untracked + git stash push -m "$STASH_NAME" success "Local changes stashed" fi @@ -499,12 +495,12 @@ if [[ "$HAS_CHANGES" == "true" ]]; then for user_path in "${USER_PATHS[@]}"; do CONFLICTED="$(git diff --name-only --diff-filter=U -- "$user_path" 2>/dev/null || true)" if [[ -n "$CONFLICTED" ]]; then - echo "$CONFLICTED" | while read -r cf; do + while IFS= read -r cf; do info " Auto-resolving (keeping yours): $cf" git checkout --theirs "$cf" 2>/dev/null || true git add "$cf" - done - RESOLVED_COUNT=$((RESOLVED_COUNT + 1)) + RESOLVED_COUNT=$((RESOLVED_COUNT + 1)) + done < <(echo "$CONFLICTED") fi done @@ -536,7 +532,9 @@ if [[ -f "$PROJECT_DIR/.env.example" ]] && [[ -f "$PROJECT_DIR/.env" ]]; then key="$(echo "$key" | xargs)" [[ ! "$key" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]] && continue if ! grep -q "^${key}=" "$PROJECT_DIR/.env" 2>/dev/null; then - # Append with default value from .env.example + # Strip inline comments and trim whitespace before appending + value="${value%%#*}" + value="$(echo "$value" | xargs)" echo "${key}=${value}" >> "$PROJECT_DIR/.env" NEW_VARS+=("$key") fi @@ -645,6 +643,12 @@ while true; do if docker compose exec -T api wget -q --spider http://localhost:4000/api/health 2>/dev/null; then break fi + # Detect container crash early (don't wait full timeout) + if ! docker compose ps api --format '{{.State}}' 2>/dev/null | grep -q "running"; then + error "API container exited unexpectedly" + docker compose logs api --tail 20 + exit 1 + fi sleep $HEALTH_INTERVAL API_WAIT=$((API_WAIT + HEALTH_INTERVAL)) if [[ $API_WAIT -ge $HEALTH_TIMEOUT ]]; then @@ -655,11 +659,21 @@ while true; do done success "API healthy (${API_WAIT}s)" -# Start everything else +# Start everything else (exclude one-shot init containers) info "Starting remaining services..." -docker compose up -d +docker compose up -d \ + --scale listmonk-init=0 \ + --scale gancio-init=0 \ + --scale vaultwarden-init=0 success "All services started" +# Restart Pangolin tunnel connector if running (may hold stale state after nginx rebuild) +if docker ps --format '{{.Names}}' | grep -q 'newt'; then + info "Restarting Pangolin tunnel connector..." + docker compose restart newt 2>/dev/null || true + success "Newt tunnel restarted" +fi + # Restart monitoring if it was running before if [[ "$MONITORING_WAS_RUNNING" == "true" ]]; then info "Restarting monitoring stack..."