Tonne of updates

This commit is contained in:
bunker-admin 2026-02-18 17:15:31 -07:00
parent 56e262ad8b
commit 1a1f12c45b
176 changed files with 12786 additions and 388 deletions

View File

@ -69,7 +69,8 @@ TEST_EMAIL_RECIPIENT=admin@cmlite.org
# --- Listmonk ---
LISTMONK_PORT=9001
LISTMONK_DB_PORT=5432
# Use 5434 to avoid conflict with main PostgreSQL (5432 internal / 5433 host)
LISTMONK_DB_PORT=5434
LISTMONK_DB_USER=listmonk
LISTMONK_DB_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS
LISTMONK_DB_NAME=listmonk
@ -83,6 +84,7 @@ LISTMONK_API_TOKEN=GENERATE_WITH_openssl_rand_hex_16
LISTMONK_ADMIN_USER=v2-api
LISTMONK_ADMIN_PASSWORD=SAME_AS_LISTMONK_API_TOKEN
LISTMONK_SYNC_ENABLED=false
LISTMONK_WEBHOOK_SECRET=
LISTMONK_PROXY_PORT=9002
# Listmonk SMTP — MailHog for development (production SMTP added as second provider if credentials set)
LISTMONK_SMTP_HOST=mailhog-changemaker
@ -124,9 +126,12 @@ ENABLE_PAYMENTS=false
ENABLE_MEDIA_FEATURES=false
MEDIA_API_PORT=4100
MEDIA_API_PUBLIC_URL=http://media-api:4100
# Used during admin Docker build to set the media API endpoint for Vite
VITE_MEDIA_API_URL=http://changemaker-media-api:4100
MEDIA_ROOT=/media/library
MEDIA_UPLOADS=/media/uploads
MAX_UPLOAD_SIZE_GB=10
PUBLIC_MEDIA_PORT=3100
VIDEO_PLAYER_DEBUG=false
# Video Analytics (Feb 2026)
@ -168,7 +173,7 @@ GENERIC_TIMEZONE=UTC
# This also controls the Vite dev proxy in local development
# Change this port to use a different local port, and the admin dev server will automatically use it
MKDOCS_PORT=4003
MKDOCS_SITE_SERVER_PORT=4001
MKDOCS_SITE_SERVER_PORT=4004
BASE_DOMAIN=https://cmlite.org
MKDOCS_PREVIEW_URL=http://mkdocs:8000
MKDOCS_DOCS_PATH=/mkdocs/docs
@ -194,6 +199,21 @@ EXCALIDRAW_URL=http://excalidraw-changemaker:80
EXCALIDRAW_EMBED_PORT=8886
EXCALIDRAW_WS_URL=wss://draw.cmlite.org
# --- Vaultwarden (Password Manager) ---
VAULTWARDEN_PORT=8445
VAULTWARDEN_URL=http://vaultwarden-changemaker:80
VAULTWARDEN_EMBED_PORT=8890
# Admin panel token (access at /admin) — generate with: openssl rand -hex 32
VAULTWARDEN_ADMIN_TOKEN=
# MUST use HTTPS — Bitwarden web vault enforces HTTPS for account creation
# Set to your Pangolin tunnel URL (e.g., https://vault.yourdomain.org)
# Local access (browsing existing vault) works on HTTP, but signup/invite requires HTTPS
VAULTWARDEN_DOMAIN=https://vault.cmlite.org
VAULTWARDEN_SIGNUPS_ALLOWED=false
VAULTWARDEN_WEBSOCKET_ENABLED=true
# SMTP security: "off" for MailHog, "starttls" or "force_tls" for production
VAULTWARDEN_SMTP_SECURITY=off
# --- MailHog ---
MAILHOG_SMTP_PORT=1025
MAILHOG_WEB_PORT=8025
@ -244,15 +264,45 @@ PANGOLIN_ENDPOINT=https://pangolin.bnkserve.org
PANGOLIN_NEWT_ID=
PANGOLIN_NEWT_SECRET=
# --- Prisma CLI (host-side only, NOT used by Docker containers) ---
# Containers resolve the DB hostname internally via docker-compose environment
# This is used when running `npx prisma migrate dev` from the host machine
DATABASE_URL=postgresql://changemaker:YOUR_POSTGRES_PASSWORD@localhost:5433/changemaker_v2
# --- Rocket.Chat (Team Chat) ---
# ENABLE_CHAT is the initial default; once saved in admin Settings, the DB value is authoritative
ENABLE_CHAT=false
ROCKETCHAT_ADMIN_USER=rcadmin
ROCKETCHAT_ADMIN_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS
ROCKETCHAT_URL=http://rocketchat-changemaker:3000
ROCKETCHAT_EMBED_PORT=8891
# --- Gancio (Event Management) ---
# Uses shared PostgreSQL (database: gancio, auto-created by init-gancio-db.sh)
GANCIO_PORT=8092
GANCIO_URL=http://gancio-changemaker:13120
GANCIO_EMBED_PORT=8892
GANCIO_BASE_URL=https://events.cmlite.org
# Gancio admin credentials for shift-to-event sync (OAuth login)
GANCIO_ADMIN_USER=admin
GANCIO_ADMIN_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS
# Enable automatic shift → Gancio event sync
GANCIO_SYNC_ENABLED=false
# --- Monitoring (only used with --profile monitoring) ---
PROMETHEUS_PORT=9090
GRAFANA_PORT=3001
GRAFANA_PORT=3005
GRAFANA_ADMIN_PASSWORD=admin
GRAFANA_ROOT_URL=http://localhost:3001
CADVISOR_PORT=8080
GRAFANA_ROOT_URL=http://localhost:3005
CADVISOR_PORT=8086
NODE_EXPORTER_PORT=9100
REDIS_EXPORTER_PORT=9121
ALERTMANAGER_PORT=9093
GOTIFY_PORT=8889
GOTIFY_ADMIN_USER=admin
GOTIFY_ADMIN_PASSWORD=admin
# --- Bunker Ops (Fleet Management) ---
INSTANCE_LABEL= # Unique label for this instance (defaults to DOMAIN)
BUNKER_OPS_ENABLED=false # Enable remote metrics push to central server
BUNKER_OPS_REMOTE_WRITE_URL= # VictoriaMetrics remote_write endpoint (e.g., https://ops.example.com/api/v1/write)

8
.gitignore vendored
View File

@ -36,8 +36,16 @@ node_modules/
# Media files (managed by Docker volumes, not git)
/media/
# Ansible per-instance override (generated by Bunker Ops)
docker-compose.override.yml
# Build output
/admin/dist/
# MkDocs core binary
/mkdocs/core
# Upgrade artifacts
/logs/
/backups/
.upgrade.lock

257
FEDERATION_PLAN.md Normal file
View File

@ -0,0 +1,257 @@
# Phase 16: Federation — Instance-to-Instance Campaign Network
## Context
Changemaker Lite instances are currently isolated islands. This feature introduces a **federated discovery network** where any instance can act as a **hub** (accepting registrations, serving a directory) and/or a **spoke** (registering with hubs, sharing campaigns). The goal is organic, admin-to-admin networking with public campaign discoverability as a secondary benefit.
**Design principles:**
- Any instance can be a hub, spoke, or both — no central authority
- Medium-depth campaign sharing: enough metadata for discovery, click-through to source
- Per-campaign federation toggle — admins choose what's shared
- Strict privacy boundary: **never** share emails, participant data, queue data, addresses, volunteer/canvass data, or credentials
- Hub admins curate their own directories — organic > control
---
## Prisma Schema Changes
**File:** `api/prisma/schema.prisma`
### New enums
```
FederationPeerStatus: PENDING | ACTIVE | REJECTED | SUSPENDED | OFFLINE
FederationRole: HUB | SPOKE
```
### New models
**FederationIdentity** (singleton — this instance's federation profile)
- `enabled`, `hubEnabled`, `hubAutoApprove`
- Instance profile: `instanceName`, `instanceDescription`, `instanceUrl`, `instanceRegion`, `instanceTags` (Json), `instanceLogoUrl`
- Ed25519 keypair: `publicKey`, `privateKey` (encrypted at rest)
- Hub description, sync interval, last sync timestamp/error
**FederationPeer** (one record per connection, in either direction)
- `role` (HUB or SPOKE), `remoteUrl` (unique per role+url)
- Remote instance profile fields (name, description, region, tags, logo, publicKey)
- Auth: `apiKey` (ours for them), `remoteApiKey` (theirs for us) — both encrypted
- Status tracking: `status`, `statusMessage`, `lastSeenAt`, `lastSyncAt`, `failureCount`
- Stats: `campaignsShared`, `responsesShared`
- Relation to `FederatedCampaign[]`
**FederatedCampaign** (cached campaign metadata from peers)
- `peerId` → FederationPeer
- Remote identifiers: `remoteCampaignId`, `remoteCampaignSlug`
- Safe metadata: title, description, emailSubject (NOT body), callToAction, coverPhoto, status, targetGovernmentLevels, featureFlags (Json), createdByName
- Aggregate stats: `emailCount`, `responseCount`
- Source instance info (denormalized): `sourceInstanceName`, `sourceInstanceUrl`, `sourceInstanceRegion`
- Staleness tracking: `lastSyncedAt`, `isStale`
- Future adoption: `adoptedAsCampaignId` (nullable FK to local Campaign)
- Unique constraint: `[peerId, remoteCampaignId]`
### Modifications to existing models
**Campaign** — add `federated Boolean @default(false)` field
**SiteSettings** — add `enableFederation Boolean @default(false)` feature toggle
---
## API Module Structure
**New directory:** `api/src/modules/federation/`
| File | Purpose |
|------|---------|
| `federation.schemas.ts` | Zod schemas: identity update, peer registration, campaign sync, directory query, list filters |
| `federation.service.ts` | Core business logic: identity CRUD, peer management, `buildSafeCampaignPayload()`, campaign sync, directory serving |
| `federation-admin.routes.ts` | SUPER_ADMIN routes: identity management, peer approve/reject/suspend, manual sync trigger |
| `federation-peer.routes.ts` | Inter-instance routes: inbound registration, campaign sync, directory, heartbeat (API-key auth) |
| `federation-public.routes.ts` | Public browsing: federated campaigns list, instance directory (no auth) |
| `federation-crypto.service.ts` | Ed25519 keypair generation, request signing/verification |
**New file:** `api/src/services/federation-sync-queue.service.ts` — BullMQ repeatable job for periodic sync
### Route table
**Admin routes** (`/api/federation/...`, SUPER_ADMIN + JWT auth):
| Method | Path | Description |
|--------|------|-------------|
| GET | `/identity` | Get federation config |
| PUT | `/identity` | Update config/profile |
| POST | `/identity/generate-keypair` | Generate Ed25519 keypair |
| GET | `/peers` | List all peers |
| POST | `/peers/register` | Register with a remote hub |
| POST | `/peers/:id/approve` | Approve incoming spoke |
| POST | `/peers/:id/reject` | Reject incoming spoke |
| POST | `/peers/:id/suspend` | Suspend peer |
| DELETE | `/peers/:id` | Remove peer |
| POST | `/sync` | Trigger manual sync |
| GET | `/sync/status` | Sync status + history |
**Peer routes** (`/api/federation/peer/...`, API-key auth via `X-Federation-Key` header):
| Method | Path | Description |
|--------|------|-------------|
| POST | `/register` | Inbound spoke registration |
| POST | `/sync` | Inbound campaign metadata push |
| GET | `/directory` | Serve campaign directory |
| GET | `/profile` | Return instance profile |
| POST | `/heartbeat` | Liveness check |
**Public routes** (`/api/federation/...`, no auth):
| Method | Path | Description |
|--------|------|-------------|
| GET | `/campaigns` | Browse federated campaigns (paginated, searchable) |
| GET | `/campaigns/:id` | Single federated campaign detail |
| GET | `/instances` | List known network instances |
### Mounting in server.ts
```
app.use('/api/federation', federationPublicRouter); // No auth — first
app.use('/api/federation', federationPeerRouter); // API-key auth
app.use('/api/federation', federationAdminRouter); // SUPER_ADMIN JWT
```
---
## Federation Protocol
### Registration handshake
1. Spoke admin enters hub URL, clicks "Register"
2. Spoke sends `POST /api/federation/peer/register` to hub with instance profile + generated API key
3. Hub creates peer record (PENDING or ACTIVE if `hubAutoApprove`)
4. Hub responds with its own API key + peer ID
5. If approved (now or later), hub calls back to spoke's `/peer/register` to complete mutual registration
6. Both instances now have each other as peers (Spoke→HUB role, Hub→SPOKE role)
### Campaign sync
- Spokes push federated campaigns to hubs on schedule (BullMQ repeatable job)
- Payload: array of safe campaign metadata + array of un-federated campaign IDs (for removal)
- Hub stores/updates `FederatedCampaign` records
- Sync includes heartbeat (updates `lastSeenAt`)
### Privacy boundary enforcement
`buildSafeCampaignPayload()` in the service layer filters campaigns to only safe fields. **Never included:** emailBody, any email addresses, user IDs, participant data, moderation internals, custom recipients, calls data.
### Offline handling
- Increment `failureCount` on sync failure; after 5 consecutive failures → status `OFFLINE`
- Mark federated campaigns as `isStale` after 24h offline
- Keep checking with exponential backoff (max 24h)
- Auto-recover when heartbeat succeeds
---
## Security
- **API-key auth:** `crypto.randomBytes(32).toString('hex')`, encrypted at rest with existing `encrypt()`/`decrypt()` utility
- **Custom middleware:** `authenticatePeer` checks `X-Federation-Key` header, verifies peer exists + is ACTIVE
- **Request signing (optional):** Ed25519 signatures on `X-Federation-Signature` header for non-repudiation (configurable, not enforced in MVP)
- **Rate limiting:** 30 req/min for peer routes, 60 req/min for public routes (separate Redis prefixes)
- **CORS:** Peer routes need permissive CORS (cross-domain by nature)
- **Input validation:** All incoming peer data Zod-validated + HTML-escaped before storage
---
## Environment Variables
Add to `api/src/config/env.ts`:
```
ENABLE_FEDERATION: z.string().default('false')
FEDERATION_SYNC_INTERVAL_MINUTES: z.coerce.number().default(60)
FEDERATION_MAX_CAMPAIGNS_PER_SYNC: z.coerce.number().default(500)
FEDERATION_PEER_TIMEOUT_MS: z.coerce.number().default(15000)
FEDERATION_MAX_PEERS: z.coerce.number().default(50)
```
---
## Admin UI
### FederationPage (`admin/src/pages/FederationPage.tsx`)
4-tab page following PangolinPage pattern:
**Tab 1 — Identity & Settings:** Toggle federation, instance profile form, keypair management, hub/spoke settings
**Tab 2 — Connected Peers:** Table of peers (name, URL, role tag, status tag, campaigns shared, last sync, actions). "Register with Hub" button opens modal. Pending incoming registrations highlighted.
**Tab 3 — Federated Campaigns:** Card grid/table of federated campaigns with search + filter (region, tags, government level). Click-through links to source instances.
**Tab 4 — Sync Status:** Last/next sync, per-peer status, manual sync button, sync history.
### Sidebar
Add to `buildMenuItems()` in `AppLayout.tsx`, gated on `settings?.enableFederation`:
```typescript
{ key: '/app/federation', icon: <GlobalOutlined />, label: 'Federation' }
```
(Using `<GlobalOutlined />` since `<GlobalOutlined />` is already imported but used for Web submenu — may use `<ClusterOutlined />` or `<DeploymentUnitOutlined />` instead)
### Route in App.tsx
```tsx
<Route path="federation" element={<ProtectedRoute requiredRoles={['SUPER_ADMIN']}><FederationPage /></ProtectedRoute>} />
```
### Campaign form integration
Add `federated` checkbox to campaign create/edit form in CampaignsPage, visible only when `settings.enableFederation` is true.
### TypeScript types
Add `FederationIdentity`, `FederationPeer`, `FederatedCampaign`, `FederationSyncStatus` interfaces to `admin/src/types/api.ts`.
### Public network page (stretch goal in MVP)
`admin/src/pages/public/FederatedCampaignsPage.tsx` at `/network` route — card grid of federated campaigns with PublicLayout dark theme.
---
## Prometheus Metrics
Add to `api/src/utils/metrics.ts`:
- `cm_federation_peers_active` (Gauge)
- `cm_federation_campaigns_shared` (Gauge)
- `cm_federation_sync_duration_seconds` (Histogram)
- `cm_federation_sync_errors_total` (Counter with `peer_id` label)
---
## Implementation Order
| Step | Description | Files Created/Modified | Depends On |
|------|-------------|----------------------|------------|
| 1 | **Prisma schema** — Add enums, 3 new models, Campaign.federated, SiteSettings.enableFederation | `api/prisma/schema.prisma` | — |
| 2 | **Migration**`npx prisma migrate dev --name add-federation` | `api/prisma/migrations/` | Step 1 |
| 3 | **Env vars** — Add federation config to env.ts + .env.example | `api/src/config/env.ts`, `.env.example` | — |
| 4 | **Crypto service** — Ed25519 keypair, sign/verify | `api/src/modules/federation/federation-crypto.service.ts` | — |
| 5 | **Schemas** — Zod validation for all federation endpoints | `api/src/modules/federation/federation.schemas.ts` | Step 1 |
| 6 | **Core service** — Identity CRUD, peer management, buildSafeCampaignPayload, campaign sync logic | `api/src/modules/federation/federation.service.ts` | Steps 2, 4, 5 |
| 7 | **Admin routes** — SUPER_ADMIN federation management | `api/src/modules/federation/federation-admin.routes.ts` | Step 6 |
| 8 | **Peer routes** — Inter-instance API with authenticatePeer middleware | `api/src/modules/federation/federation-peer.routes.ts` | Step 6 |
| 9 | **Public routes** — Browsing federated campaigns | `api/src/modules/federation/federation-public.routes.ts` | Step 6 |
| 10 | **Rate limiting** — Add federation rate limiters | `api/src/middleware/rate-limit.ts` | — |
| 11 | **Server mounting** — Import + mount routers, start sync queue | `api/src/server.ts` | Steps 7-10 |
| 12 | **Sync queue** — BullMQ repeatable job for periodic sync | `api/src/services/federation-sync-queue.service.ts` | Step 6 |
| 13 | **Metrics** — Prometheus counters/gauges | `api/src/utils/metrics.ts` | — |
| 14 | **Campaign form** — Add `federated` to schemas + service + CampaignsPage checkbox | `api/src/modules/influence/campaigns/campaigns.schemas.ts`, `campaigns.service.ts`, `admin/src/pages/CampaignsPage.tsx` | Step 2 |
| 15 | **Frontend types** — Federation TypeScript interfaces | `admin/src/types/api.ts` | — |
| 16 | **FederationPage** — 4-tab admin page | `admin/src/pages/FederationPage.tsx` | Steps 7, 15 |
| 17 | **Sidebar + routing** — Menu item + route in AppLayout/App.tsx | `admin/src/components/AppLayout.tsx`, `admin/src/App.tsx` | Step 16 |
| 18 | **Public network page** (stretch) — Federated campaigns browse | `admin/src/pages/public/FederatedCampaignsPage.tsx` | Steps 9, 15 |
---
## Future Extensions (not in MVP, but models accommodate)
- **Campaign adoption** — "Fork" a federated campaign locally (`FederatedCampaign.adoptedAsCampaignId`)
- **Cross-instance response sharing** — New `FederatedResponse` model synced alongside campaigns
- **Named networks/coalitions**`FederationNetwork` + `FederationNetworkMember` models for named alliances
- **Hub-of-hubs discovery** — Hubs share known-hub lists for transitive discovery (gossip protocol)
---
## Verification
1. **Two-instance test:** Run two API instances on different ports, enable federation on both, register one with the other
2. **Campaign sync:** Create a federated campaign on spoke, verify it appears in hub's directory
3. **Privacy boundary:** Inspect sync payloads — verify no emails, user IDs, or email bodies leak
4. **Offline handling:** Stop one instance, verify the other marks it OFFLINE after 5 failed syncs, then recovers on restart
5. **Rate limiting:** Hit peer endpoints rapidly, verify 429 responses after threshold
6. **Feature gate:** Disable federation in settings, verify all routes return 403/hidden
7. **UI:** Verify sidebar item appears/hides with feature toggle, all 4 tabs functional

View File

@ -35,6 +35,9 @@ import GiteaPage from '@/pages/GiteaPage';
import MailHogPage from '@/pages/MailHogPage';
import MiniQRPage from '@/pages/MiniQRPage';
import ExcalidrawPage from '@/pages/ExcalidrawPage';
import VaultwardenPage from '@/pages/VaultwardenPage';
import RocketChatPage from '@/pages/RocketChatPage';
import GancioPage from '@/pages/GancioPage';
import SettingsPage from '@/pages/SettingsPage';
import PangolinPage from '@/pages/PangolinPage';
import ObservabilityPage from '@/pages/ObservabilityPage';
@ -67,6 +70,7 @@ import PlaylistViewerPage from '@/pages/public/PlaylistViewerPage';
import PlaylistManagementPage from '@/pages/media/PlaylistManagementPage';
import MyStatsPage from '@/pages/public/MyStatsPage';
import MySettingsPage from '@/pages/public/MySettingsPage';
import VolunteerChatPage from '@/pages/volunteer/VolunteerChatPage';
import PricingPage from '@/pages/public/PricingPage';
import ShopPage from '@/pages/public/ShopPage';
import DonatePage from '@/pages/public/DonatePage';
@ -235,6 +239,7 @@ export default function App() {
<Route path="/volunteer/activity" element={<MyActivityPage />} />
<Route path="/volunteer/shifts" element={<VolunteerShiftsPage />} />
<Route path="/volunteer/routes" element={<MyRoutesPage />} />
<Route path="/volunteer/chat" element={<VolunteerChatPage />} />
</Route>
{/* Redirect old canvass routes to map with query param */}
@ -415,6 +420,30 @@ export default function App() {
</ProtectedRoute>
}
/>
<Route
path="services/vaultwarden"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<VaultwardenPage />
</ProtectedRoute>
}
/>
<Route
path="services/rocketchat"
element={
<ProtectedRoute>
<RocketChatPage />
</ProtectedRoute>
}
/>
<Route
path="services/gancio"
element={
<ProtectedRoute requiredRoles={['SUPER_ADMIN']}>
<GancioPage />
</ProtectedRoute>
}
/>
<Route
path="settings"
element={

View File

@ -41,6 +41,7 @@ import {
HeartOutlined,
CrownOutlined,
PictureOutlined,
LockOutlined,
} from '@ant-design/icons';
import type { MenuProps } from 'antd';
import { api } from '@/lib/api';
@ -166,12 +167,15 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, isS
{ key: '/app/tunnel', icon: <CloudServerOutlined />, label: 'Tunnel' },
{ key: '/app/observability', icon: <LineChartOutlined />, label: 'Monitoring' },
{ key: '/app/services/nocodb', icon: <DatabaseOutlined />, label: 'Database' },
{ key: '/app/services/vaultwarden', icon: <LockOutlined />, label: 'Vault' },
{ key: '/app/services/mailhog', icon: <MailOutlined />, label: 'MailHog' },
]},
{ type: 'group', label: 'Tools', children: [
{ key: '/app/services/n8n', icon: <ApiOutlined />, label: 'Workflows' },
{ key: '/app/services/gitea', icon: <BranchesOutlined />, label: 'Git' },
{ key: '/app/services/excalidraw', icon: <EditOutlined />, label: 'Whiteboard' },
...(settings?.enableChat ? [{ key: '/app/services/rocketchat', icon: <MessageOutlined />, label: 'Team Chat' }] : []),
{ key: '/app/services/gancio', icon: <CalendarOutlined />, label: 'Events' },
{ key: '/app/services/miniqr', icon: <QrcodeOutlined />, label: 'QR Codes' },
]},
],

View File

@ -348,6 +348,33 @@ function generateBlockHtml(type: string, defaults: Record<string, unknown>): str
</div>
</section>`;
}
case 'gancio-events': {
const maxlength = defaults.maxlength || 10;
const evTheme = (defaults.theme as string) || 'dark';
const tags = (defaults.tags as string) || '';
const title = (defaults.title as string) || 'Upcoming Events';
return `
<section style="padding: 60px 40px;">
<div class="gancio-events-block"
data-maxlength="${maxlength}"
data-theme="${evTheme}"
data-tags="${tags}"
data-title="${title}"
style="max-width: 800px; margin: 0 auto;">
<div style="aspect-ratio: 16/9; background: linear-gradient(135deg, #1a472a 0%, #2d5a27 100%); border-radius: 12px; display: flex; align-items: center; justify-content: center; position: relative; overflow: hidden;">
<div style="text-align: center; color: #fff; padding: 24px;">
<svg style="width: 64px; height: 64px; margin-bottom: 16px; opacity: 0.9;" fill="currentColor" viewBox="0 0 1024 1024">
<path d="M880 184H712v-64c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v64H384v-64c0-4.4-3.6-8-8-8h-56c-4.4 0-8 3.6-8 8v64H144c-17.7 0-32 14.3-32 32v664c0 17.7 14.3 32 32 32h736c17.7 0 32-14.3 32-32V216c0-17.7-14.3-32-32-32z"/>
</svg>
<p style="margin: 0; font-size: 1.1rem; font-weight: 600;">${title}</p>
<p style="margin: 8px 0 0; font-size: 0.9rem; opacity: 0.8;">Theme: ${evTheme} | Max: ${maxlength} events</p>
${tags ? `<p style="margin: 4px 0 0; font-size: 0.85rem; opacity: 0.7;">Tags: ${tags}</p>` : ''}
<p style="margin: 12px 0 0; font-size: 0.75rem; opacity: 0.6; font-style: italic;">Events will render on published page</p>
</div>
</div>
</div>
</section>`;
}
default:
return `<section style="padding: 40px; text-align: center;"><p>Custom block: ${type}</p></section>`;
}

View File

@ -109,6 +109,17 @@ export default function PublicLayout() {
const footerText = settings?.footerText ?? 'Powered by Changemaker Lite';
const logoUrl = settings?.organizationLogoUrl;
// Resolve Gancio URL — subdomain in production, direct port in dev
const gancioUrl = (() => {
const host = window.location.hostname;
if (host !== 'localhost' && host.includes('.')) {
const protocol = window.location.protocol;
const baseDomain = host.split('.').slice(-2).join('.');
return `${protocol}//events.${baseDomain}`;
}
return `http://localhost:8092`;
})();
// Dynamic document title + favicon for public pages
useEffect(() => {
if (!settings) return;
@ -191,6 +202,9 @@ export default function PublicLayout() {
<NavLink to="/shifts" icon={<CalendarOutlined />} label="Shifts" active={activeRoute === '/shifts'} />
</>
)}
{settings?.enableEvents !== false && (
<NavExternalLink href={gancioUrl} icon={<CalendarOutlined />} label="Events" />
)}
{settings?.enableMediaFeatures !== false && (
<NavLink to="/gallery" icon={<PlayCircleOutlined />} label="Gallery" active={activeRoute === '/gallery'} />
)}
@ -239,6 +253,12 @@ export default function PublicLayout() {
<Link to="/shifts" style={{ color: 'rgba(255,255,255,0.5)', fontSize: 12 }}>Shifts</Link>
</>
)}
{settings?.enableEvents !== false && (
<>
{' • '}
<a href={gancioUrl} target="_blank" rel="noopener noreferrer" style={{ color: 'rgba(255,255,255,0.5)', fontSize: 12 }}>Events</a>
</>
)}
{settings?.enableMediaFeatures !== false && (
<>
{' • '}
@ -310,6 +330,24 @@ export default function PublicLayout() {
<span>{item.label}</span>
</Link>
))}
{settings?.enableEvents !== false && (
<a
href={gancioUrl}
target="_blank"
rel="noopener noreferrer"
onClick={() => 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,
}}
>
<CalendarOutlined />
<span>Events</span>
</a>
)}
<div style={{ borderTop: '1px solid rgba(255,255,255,0.1)', margin: '8px 24px' }} />
{isAuthenticated ? (
<span

View File

@ -1,3 +1,4 @@
import { useMemo } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { theme } from 'antd';
import {
@ -5,10 +6,12 @@ import {
CalendarOutlined,
HistoryOutlined,
NodeIndexOutlined,
MessageOutlined,
MenuOutlined,
} from '@ant-design/icons';
import { useSettingsStore } from '@/stores/settings.store';
const NAV_ITEMS = [
const BASE_NAV_ITEMS = [
{ key: '/volunteer', icon: EnvironmentOutlined, label: 'Map' },
{ key: '/volunteer/shifts', icon: CalendarOutlined, label: 'Shifts' },
{ key: '/volunteer/activity', icon: HistoryOutlined, label: 'Activity' },
@ -25,6 +28,15 @@ export default function VolunteerFooterNav({ style, onMenuOpen, menuActive = fal
const navigate = useNavigate();
const location = useLocation();
const { token } = theme.useToken();
const { settings } = useSettingsStore();
const NAV_ITEMS = useMemo(() => {
const items = [...BASE_NAV_ITEMS];
if (settings?.enableChat) {
items.push({ key: '/volunteer/chat', icon: MessageOutlined, label: 'Chat' });
}
return items;
}, [settings?.enableChat]);
const activeKey = (() => {
const path = location.pathname;

View File

@ -0,0 +1,123 @@
import { useState, useEffect } from 'react';
import { Card, Table, Progress, Tag, Button, Spin } from 'antd';
import { ExportOutlined, FundOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import type { CutCampaignAnalytics } from '@/types/api';
const SUPPORT_LABELS: Record<string, { label: string; color: string }> = {
LEVEL_1: { label: 'Strong', color: '#27ae60' },
LEVEL_2: { label: 'Likely', color: '#f1c40f' },
LEVEL_3: { label: 'Unsure', color: '#e67e22' },
LEVEL_4: { label: 'Oppose', color: '#e74c3c' },
};
interface Props {
onExportCut?: (cutId: string) => void;
}
export default function CutCampaignAnalyticsCard({ onExportCut }: Props) {
const [data, setData] = useState<CutCampaignAnalytics[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
api.get<{ cuts: CutCampaignAnalytics[] }>('/map/canvass/analytics/cuts')
.then(({ data: res }) => setData(res.cuts))
.catch(() => {})
.finally(() => setLoading(false));
}, []);
if (loading) {
return (
<Card title={<><FundOutlined style={{ marginRight: 6 }} />Campaign Readiness</>} size="small">
<div style={{ textAlign: 'center', padding: 24 }}><Spin size="small" /></div>
</Card>
);
}
if (data.length === 0) return null;
return (
<Card
title={<><FundOutlined style={{ marginRight: 6 }} />Campaign Readiness by Cut</>}
size="small"
>
<Table
dataSource={data}
rowKey="cutId"
size="small"
pagination={false}
scroll={{ x: 600 }}
columns={[
{
title: 'Cut',
dataIndex: 'cutName',
key: 'cutName',
width: 150,
ellipsis: true,
},
{
title: 'Completion',
key: 'completion',
width: 140,
render: (_, r) => (
<Progress
percent={r.completionPct}
size="small"
strokeColor={r.completionPct >= 80 ? '#52c41a' : r.completionPct >= 40 ? '#faad14' : '#ff4d4f'}
/>
),
},
{
title: 'Addresses',
dataIndex: 'totalAddresses',
key: 'total',
width: 80,
align: 'right' as const,
},
{
title: 'With Email',
dataIndex: 'addressesWithEmail',
key: 'email',
width: 90,
align: 'right' as const,
render: (v: number) => (
<span style={{ color: v > 0 ? '#52c41a' : '#999' }}>{v}</span>
),
},
{
title: 'Support',
key: 'support',
width: 180,
render: (_, r) => (
<div style={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
{Object.entries(r.supportBreakdown).map(([level, count]) => {
const info = SUPPORT_LABELS[level];
if (!info || count === 0) return null;
return (
<Tag key={level} color={info.color} style={{ margin: 0, fontSize: 10, padding: '0 4px' }}>
{info.label}: {count}
</Tag>
);
})}
</div>
),
},
...(onExportCut ? [{
title: '',
key: 'actions',
width: 60,
render: (_: unknown, r: CutCampaignAnalytics) => (
<Button
type="link"
size="small"
icon={<ExportOutlined />}
onClick={() => onExportCut(r.cutId)}
disabled={r.addressesWithEmail === 0}
/>
),
}] : []),
]}
/>
</Card>
);
}

View File

@ -0,0 +1,297 @@
import { useState, useEffect, useCallback } from 'react';
import {
Modal, Form, Select, Checkbox, Slider, DatePicker, Switch,
Button, Statistic, Row, Col, Descriptions, Alert, Spin, App,
} from 'antd';
import { ExportOutlined, EyeOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import type {
Cut, Campaign, ExportContactsPreviewResult, ExportContactsResult,
} from '@/types/api';
const OUTCOME_OPTIONS = [
{ label: 'Spoke With', value: 'SPOKE_WITH' },
{ label: 'Left Literature', value: 'LEFT_LITERATURE' },
{ label: 'Come Back Later', value: 'COME_BACK_LATER' },
{ label: 'Not Home', value: 'NOT_HOME' },
{ label: 'Refused', value: 'REFUSED' },
{ label: 'Moved', value: 'MOVED' },
{ label: 'Already Voted', value: 'ALREADY_VOTED' },
];
const SUPPORT_LEVEL_MARKS: Record<number, string> = {
1: 'Strong',
2: 'Likely',
3: 'Unsure',
4: 'Oppose',
};
interface Props {
open: boolean;
onClose: () => void;
cuts: Cut[];
/** If provided, pre-fills the campaign selector */
preselectedCampaignId?: string;
/** If provided, pre-fills the cut selector */
preselectedCutIds?: string[];
}
export default function ExportContactsModal({
open, onClose, cuts, preselectedCampaignId, preselectedCutIds,
}: Props) {
const { message } = App.useApp();
const [form] = Form.useForm();
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
const [preview, setPreview] = useState<ExportContactsPreviewResult | null>(null);
const [previewing, setPreviewing] = useState(false);
const [exporting, setExporting] = useState(false);
const [loadingCampaigns, setLoadingCampaigns] = useState(false);
// Load campaigns on mount
useEffect(() => {
if (!open) return;
setLoadingCampaigns(true);
api.get('/campaigns', { params: { limit: 100, status: 'ACTIVE' } })
.then(({ data }) => setCampaigns(data.campaigns || []))
.catch(() => {})
.finally(() => setLoadingCampaigns(false));
}, [open]);
// Set defaults when modal opens
useEffect(() => {
if (open) {
form.setFieldsValue({
cutIds: preselectedCutIds || [],
outcomes: ['SPOKE_WITH', 'LEFT_LITERATURE'],
supportRange: [1, 2],
hasEmail: true,
hasSign: false,
campaignId: preselectedCampaignId || undefined,
});
setPreview(null);
}
}, [open, form, preselectedCampaignId, preselectedCutIds]);
const handlePreview = useCallback(async () => {
try {
const values = await form.validateFields(['cutIds']);
const allValues = form.getFieldsValue(true);
setPreviewing(true);
setPreview(null);
const body: Record<string, unknown> = {
cutIds: values.cutIds,
};
if (allValues.outcomes?.length) body.outcomes = allValues.outcomes;
if (allValues.supportRange) {
body.supportLevelMin = allValues.supportRange[0];
body.supportLevelMax = allValues.supportRange[1];
}
if (allValues.hasEmail) body.hasEmail = true;
if (allValues.hasSign) body.hasSign = true;
if (allValues.visitedSince) {
body.visitedSince = allValues.visitedSince.toISOString();
}
const res = await api.post<ExportContactsPreviewResult>(
'/map/canvass/export-contacts/preview',
body,
);
setPreview(res.data);
} catch {
message.error('Failed to generate preview');
} finally {
setPreviewing(false);
}
}, [form, message]);
const handleExport = async () => {
try {
const allValues = await form.validateFields();
if (!allValues.campaignId) {
message.warning('Please select a target campaign');
return;
}
setExporting(true);
const body: Record<string, unknown> = {
cutIds: allValues.cutIds,
campaignId: allValues.campaignId,
};
if (allValues.outcomes?.length) body.outcomes = allValues.outcomes;
if (allValues.supportRange) {
body.supportLevelMin = allValues.supportRange[0];
body.supportLevelMax = allValues.supportRange[1];
}
if (allValues.hasEmail) body.hasEmail = true;
if (allValues.hasSign) body.hasSign = true;
if (allValues.visitedSince) {
body.visitedSince = allValues.visitedSince.toISOString();
}
const res = await api.post<ExportContactsResult>(
'/map/canvass/export-contacts',
body,
);
message.success(
`Exported ${res.data.created} contacts to "${res.data.campaignTitle}"` +
(res.data.skippedDuplicate ? ` (${res.data.skippedDuplicate} duplicates skipped)` : ''),
);
onClose();
} catch {
message.error('Export failed');
} finally {
setExporting(false);
}
};
return (
<Modal
title="Export Canvass Contacts to Campaign"
open={open}
onCancel={onClose}
width={640}
footer={[
<Button key="cancel" onClick={onClose}>Cancel</Button>,
<Button
key="preview"
icon={<EyeOutlined />}
onClick={handlePreview}
loading={previewing}
>
Preview
</Button>,
<Button
key="export"
type="primary"
icon={<ExportOutlined />}
onClick={handleExport}
loading={exporting}
disabled={!preview || preview.contactsWithEmail === 0}
>
Export
</Button>,
]}
>
<Form form={form} layout="vertical" size="small">
<Form.Item
name="cutIds"
label="Cuts"
rules={[{ required: true, message: 'Select at least one cut' }]}
>
<Select
mode="multiple"
placeholder="Select cuts to export from"
options={cuts.map(c => ({ label: c.name, value: c.id }))}
maxTagCount={3}
/>
</Form.Item>
<Row gutter={16}>
<Col xs={24} sm={12}>
<Form.Item name="outcomes" label="Visit Outcomes">
<Checkbox.Group options={OUTCOME_OPTIONS} />
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item name="supportRange" label="Support Level Range">
<Slider
range
min={1}
max={4}
marks={SUPPORT_LEVEL_MARKS}
tooltip={{ formatter: (v) => SUPPORT_LEVEL_MARKS[v ?? 1] }}
/>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col xs={12} sm={8}>
<Form.Item name="hasEmail" label="Has Email" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
<Col xs={12} sm={8}>
<Form.Item name="hasSign" label="Has Sign" valuePropName="checked">
<Switch />
</Form.Item>
</Col>
<Col xs={24} sm={8}>
<Form.Item name="visitedSince" label="Visited Since">
<DatePicker style={{ width: '100%' }} />
</Form.Item>
</Col>
</Row>
<Form.Item
name="campaignId"
label="Target Campaign"
rules={[{ required: true, message: 'Select a campaign' }]}
>
<Select
placeholder="Select campaign"
loading={loadingCampaigns}
showSearch
optionFilterProp="label"
options={campaigns.map(c => ({ label: `${c.title} (${c.status})`, value: c.id }))}
/>
</Form.Item>
</Form>
{/* Preview Results */}
{previewing && (
<div style={{ textAlign: 'center', padding: 16 }}>
<Spin />
</div>
)}
{preview && !previewing && (
<div style={{ marginTop: 8 }}>
<Row gutter={16}>
<Col span={8}>
<Statistic title="Total Contacts" value={preview.totalContacts} />
</Col>
<Col span={8}>
<Statistic
title="With Email"
value={preview.contactsWithEmail}
valueStyle={{ color: preview.contactsWithEmail > 0 ? '#52c41a' : '#ff4d4f' }}
/>
</Col>
<Col span={8}>
<Statistic
title="No Email"
value={preview.totalContacts - preview.contactsWithEmail}
valueStyle={{ color: '#faad14' }}
/>
</Col>
</Row>
{preview.byCut.length > 0 && (
<Descriptions column={1} size="small" style={{ marginTop: 12 }} title="By Cut">
{preview.byCut.map(c => (
<Descriptions.Item key={c.cutId} label={c.cutName}>
{c.contacts} contacts ({c.withEmail} with email)
</Descriptions.Item>
))}
</Descriptions>
)}
{preview.contactsWithEmail === 0 && (
<Alert
type="warning"
message="No contacts with email addresses found. Adjust your filters."
showIcon
style={{ marginTop: 12 }}
/>
)}
</div>
)}
</Modal>
);
}

View File

@ -0,0 +1,145 @@
import { useState, useEffect, useCallback } from 'react';
import { Card, Typography, Segmented, Button, Spin, Flex } from 'antd';
import {
CalendarOutlined,
MailOutlined,
CompassOutlined,
UserAddOutlined,
MessageOutlined,
HistoryOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { api } from '@/lib/api';
import type { ActivityFeedResult, ActivityItem } from '@/types/api';
dayjs.extend(relativeTime);
const { Text } = Typography;
const TYPE_CONFIG: Record<ActivityItem['type'], { color: string; icon: React.ReactNode }> = {
shift_signup: { color: '#eb2f96', icon: <CalendarOutlined style={{ fontSize: 10 }} /> },
response_submitted: { color: '#faad14', icon: <MessageOutlined style={{ fontSize: 10 }} /> },
canvass_completed: { color: '#52c41a', icon: <CompassOutlined style={{ fontSize: 10 }} /> },
email_sent: { color: '#1890ff', icon: <MailOutlined style={{ fontSize: 10 }} /> },
user_created: { color: '#722ed1', icon: <UserAddOutlined style={{ fontSize: 10 }} /> },
};
const MODULE_OPTIONS = [
{ label: 'All', value: 'all' },
{ label: 'Map', value: 'map' },
{ label: 'Influence', value: 'influence' },
{ label: 'Users', value: 'users' },
];
function ActivityRow({ item }: { item: ActivityItem }) {
const config = TYPE_CONFIG[item.type];
return (
<Flex
align="center"
gap={6}
style={{
padding: '3px 0',
borderBottom: '1px solid rgba(255,255,255,0.04)',
lineHeight: 1.3,
}}
>
<span style={{ color: config.color, flexShrink: 0, width: 14, textAlign: 'center' }}>{config.icon}</span>
<Text strong style={{ fontSize: 11, flexShrink: 0 }}>{item.title}</Text>
<Text
type="secondary"
style={{
fontSize: 11,
flex: 1,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{item.description}
</Text>
<Text type="secondary" style={{ fontSize: 9, flexShrink: 0, whiteSpace: 'nowrap' }}>
{dayjs(item.timestamp).fromNow(true)}
</Text>
</Flex>
);
}
export default function ActivityFeedCard() {
const [items, setItems] = useState<ActivityItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [module, setModule] = useState('all');
const [loading, setLoading] = useState(true);
const fetchActivity = useCallback(async (p: number, mod: string, append: boolean) => {
setLoading(true);
try {
const res = await api.get<ActivityFeedResult>('/dashboard/activity', {
params: { page: p, limit: 10, module: mod },
});
if (append) {
setItems(prev => [...prev, ...res.data.items]);
} else {
setItems(res.data.items);
}
setTotal(res.data.total);
} catch {
// non-critical
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
setPage(1);
fetchActivity(1, module, false);
}, [module, fetchActivity]);
const handleLoadMore = () => {
const next = page + 1;
setPage(next);
fetchActivity(next, module, true);
};
const hasMore = items.length < total;
return (
<Card
title={<span style={{ fontSize: 13 }}><HistoryOutlined style={{ marginRight: 5 }} />Recent Activity</span>}
size="small"
extra={
<Segmented
size="small"
value={module}
onChange={(val) => setModule(val as string)}
options={MODULE_OPTIONS}
/>
}
styles={{ body: { padding: '4px 12px 6px' } }}
style={{ height: '100%' }}
>
{loading && items.length === 0 ? (
<div style={{ textAlign: 'center', padding: 8 }}><Spin size="small" /></div>
) : items.length === 0 ? (
<Text type="secondary" style={{ fontSize: 11, display: 'block', padding: '6px 0' }}>No recent activity</Text>
) : (
<>
<div style={{ maxHeight: 240, overflowY: 'auto' }}>
{items.map(item => (
<ActivityRow key={item.id} item={item} />
))}
</div>
{hasMore && (
<div style={{ textAlign: 'center', paddingTop: 2 }}>
<Button size="small" type="link" onClick={handleLoadMore} loading={loading} style={{ fontSize: 11 }}>
Load more
</Button>
</div>
)}
</>
)}
</Card>
);
}

View File

@ -0,0 +1,120 @@
import { useState, useEffect, useCallback } from 'react';
import { Card, Typography, Spin, Tag, Flex, Button, Tooltip } from 'antd';
import {
MessageOutlined,
ReloadOutlined,
RobotOutlined,
UserOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { api } from '@/lib/api';
import type { ChatSummaryResult, ChatMessage } from '@/types/api';
dayjs.extend(relativeTime);
const { Text } = Typography;
const CHANNEL_COLORS: Record<string, string> = {
general: 'default',
shifts: 'magenta',
canvassing: 'green',
campaigns: 'purple',
};
function ChatRow({ message }: { message: ChatMessage }) {
const cleanText = message.text
.replace(/\*\*(.*?)\*\*/g, '$1')
.replace(/\*(.*?)\*/g, '$1');
return (
<Tooltip title={dayjs(message.timestamp).format('MMM D, h:mm A')} placement="left">
<Flex
align="center"
gap={4}
style={{
padding: '3px 0',
borderBottom: '1px solid rgba(255,255,255,0.04)',
lineHeight: 1.3,
}}
>
{message.isBot
? <RobotOutlined style={{ fontSize: 10, color: '#1890ff', flexShrink: 0 }} />
: <UserOutlined style={{ fontSize: 10, color: '#666', flexShrink: 0 }} />
}
<Text strong style={{ fontSize: 11, flexShrink: 0 }}>{message.username}</Text>
<Tag
color={CHANNEL_COLORS[message.channel] || 'default'}
style={{ fontSize: 9, margin: 0, padding: '0 3px', lineHeight: '16px', flexShrink: 0 }}
>
#{message.channel}
</Tag>
<Text
type="secondary"
style={{
fontSize: 11,
flex: 1,
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{cleanText}
</Text>
<Text type="secondary" style={{ fontSize: 9, flexShrink: 0, whiteSpace: 'nowrap' }}>
{dayjs(message.timestamp).fromNow(true)}
</Text>
</Flex>
</Tooltip>
);
}
export default function ChatNotifierCard() {
const [result, setResult] = useState<ChatSummaryResult | null>(null);
const [loading, setLoading] = useState(true);
const fetchChat = useCallback(async () => {
setLoading(true);
try {
const res = await api.get<ChatSummaryResult>('/dashboard/chat-summary');
setResult(res.data);
} catch {
// non-critical widget
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchChat();
const interval = setInterval(fetchChat, 2 * 60_000);
return () => clearInterval(interval);
}, [fetchChat]);
if (result && !result.enabled) return null;
return (
<Card
title={<span style={{ fontSize: 13 }}><MessageOutlined style={{ marginRight: 5 }} />Team Chat</span>}
size="small"
extra={
<Button type="text" size="small" icon={<ReloadOutlined spin={loading} style={{ fontSize: 11 }} />} onClick={fetchChat} />
}
styles={{ body: { padding: '4px 12px 6px' } }}
style={{ height: '100%' }}
>
{loading && !result ? (
<div style={{ textAlign: 'center', padding: 8 }}><Spin size="small" /></div>
) : result && result.messages.length > 0 ? (
<div style={{ maxHeight: 240, overflowY: 'auto' }}>
{result.messages.map(msg => (
<ChatRow key={msg.id} message={msg} />
))}
</div>
) : (
<Text type="secondary" style={{ fontSize: 11, display: 'block', padding: '6px 0' }}>No recent messages</Text>
)}
</Card>
);
}

View File

@ -0,0 +1,108 @@
import { useState, useEffect, useCallback } from 'react';
import { Card, Typography, Spin, Tag, Flex, Tooltip, Button } from 'antd';
import {
CalendarOutlined,
ClockCircleOutlined,
EnvironmentOutlined,
ReloadOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import { api } from '@/lib/api';
import type { TodayEventsResult, TodayEvent } from '@/types/api';
const { Text } = Typography;
function formatTime(iso: string): string {
return dayjs(iso).format('h:mm A');
}
function EventRow({ event }: { event: TodayEvent }) {
const start = formatTime(event.startTime);
const end = event.endTime ? formatTime(event.endTime) : null;
const timeStr = end ? `${start}-${end}` : start;
return (
<Flex
align="center"
gap={6}
style={{
padding: '3px 0',
borderBottom: '1px solid rgba(255,255,255,0.04)',
lineHeight: 1.3,
}}
>
<Text type="secondary" style={{ fontSize: 10, flexShrink: 0, width: 82, whiteSpace: 'nowrap' }}>
<ClockCircleOutlined style={{ marginRight: 3, fontSize: 9 }} />
{timeStr}
</Text>
<Text strong style={{ fontSize: 11, flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{event.title}
</Text>
{event.placeName && (
<Tooltip title={event.placeName}>
<Text type="secondary" style={{ fontSize: 10, flexShrink: 0, maxWidth: 100, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<EnvironmentOutlined style={{ marginRight: 2, fontSize: 9 }} />
{event.placeName}
</Text>
</Tooltip>
)}
{event.tags.length > 0 && (
<Tag style={{ fontSize: 9, margin: 0, padding: '0 3px', lineHeight: '16px', flexShrink: 0 }}>
{event.tags[0]}
</Tag>
)}
</Flex>
);
}
export default function TodayEventsCard() {
const [result, setResult] = useState<TodayEventsResult | null>(null);
const [loading, setLoading] = useState(true);
const fetchEvents = useCallback(async () => {
setLoading(true);
try {
const res = await api.get<TodayEventsResult>('/dashboard/today-events');
setResult(res.data);
} catch {
// non-critical widget
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchEvents();
const interval = setInterval(fetchEvents, 5 * 60_000);
return () => clearInterval(interval);
}, [fetchEvents]);
if (result && !result.enabled) return null;
return (
<Card
title={<span style={{ fontSize: 13 }}><CalendarOutlined style={{ marginRight: 5 }} />Today's Events</span>}
size="small"
extra={
<Flex align="center" gap={6}>
{result && <Text type="secondary" style={{ fontSize: 10 }}>{result.total} event{result.total !== 1 ? 's' : ''}</Text>}
<Button type="text" size="small" icon={<ReloadOutlined spin={loading} style={{ fontSize: 11 }} />} onClick={fetchEvents} />
</Flex>
}
styles={{ body: { padding: '4px 12px 6px' } }}
style={{ height: '100%' }}
>
{loading && !result ? (
<div style={{ textAlign: 'center', padding: 8 }}><Spin size="small" /></div>
) : result && result.events.length > 0 ? (
<>
{result.events.map(event => (
<EventRow key={event.id} event={event} />
))}
</>
) : (
<Text type="secondary" style={{ fontSize: 11, display: 'block', padding: '6px 0' }}>No events scheduled today</Text>
)}
</Card>
);
}

View File

@ -26,6 +26,7 @@ import {
LinkOutlined,
EyeOutlined,
QuestionCircleOutlined,
ExportOutlined,
} from '@ant-design/icons';
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
import dayjs from 'dayjs';
@ -40,8 +41,10 @@ import type {
CampaignsListParams,
CreateCampaignPayload,
UpdateCampaignPayload,
Cut,
} from '@/types/api';
import CampaignEmailsDrawer from '@/pages/CampaignEmailsDrawer';
import ExportContactsModal from '@/components/canvass/ExportContactsModal';
const { TextArea } = Input;
@ -86,6 +89,9 @@ export default function CampaignsPage() {
const [editingCampaign, setEditingCampaign] = useState<Campaign | null>(null);
const [emailsDrawerOpen, setEmailsDrawerOpen] = useState(false);
const [emailsCampaign, setEmailsCampaign] = useState<Campaign | null>(null);
const [exportOpen, setExportOpen] = useState(false);
const [exportCampaignId, setExportCampaignId] = useState<string | undefined>();
const [cuts, setCuts] = useState<Cut[]>([]);
const [createForm] = Form.useForm();
const [editForm] = Form.useForm();
@ -169,6 +175,16 @@ export default function CampaignsPage() {
}
};
const openExport = useCallback((campaignId: string) => {
setExportCampaignId(campaignId);
setExportOpen(true);
if (cuts.length === 0) {
api.get('/map/cuts', { params: { limit: 100 } })
.then(({ data }) => setCuts(data.cuts ?? data))
.catch(() => {});
}
}, [cuts.length]);
const openEdit = (campaign: Campaign) => {
setEditingCampaign(campaign);
editForm.setFieldsValue({
@ -287,6 +303,14 @@ export default function CampaignsPage() {
}}
/>
</Tooltip>
<Tooltip title="Target from canvass">
<Button
type="link"
size="small"
icon={<ExportOutlined />}
onClick={() => openExport(record.id)}
/>
</Tooltip>
<Tooltip title="Edit campaign">
<Button
type="link"
@ -519,6 +543,14 @@ export default function CampaignsPage() {
setEmailsCampaign(null);
}}
/>
{/* Export Canvass Contacts Modal */}
<ExportContactsModal
open={exportOpen}
onClose={() => { setExportOpen(false); setExportCampaignId(undefined); }}
cuts={cuts}
preselectedCampaignId={exportCampaignId}
/>
</>
);
}

View File

@ -10,6 +10,7 @@ import {
HistoryOutlined,
EyeOutlined,
EyeInvisibleOutlined,
ExportOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import { useOutletContext } from 'react-router-dom';
@ -26,6 +27,8 @@ import type { PaginationMeta, Cut, MapSettings } from '@/types/api';
import type { SessionRoute } from '@/types/tracking';
import AdminLiveMap from '@/components/canvass/AdminLiveMap';
import HistoricalRoutesDrawer from '@/components/canvass/HistoricalRoutesDrawer';
import ExportContactsModal from '@/components/canvass/ExportContactsModal';
import CutCampaignAnalyticsCard from '@/components/canvass/CutCampaignAnalyticsCard';
export default function CanvassDashboardPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
@ -45,6 +48,8 @@ export default function CanvassDashboardPage() {
const [historyOpen, setHistoryOpen] = useState(false);
const [_historyRoute, setHistoryRoute] = useState<SessionRoute | null>(null);
const [visibleCutIds, setVisibleCutIds] = useState<Set<string>>(new Set());
const [exportOpen, setExportOpen] = useState(false);
const [exportPreselectedCuts, setExportPreselectedCuts] = useState<string[]>([]);
useEffect(() => {
setPageHeader({ title: 'Canvassing', fullBleed: true });
@ -201,6 +206,13 @@ export default function CanvassDashboardPage() {
styles={{ body: { padding: 0 } }}
extra={
<Space>
<Button
size="small"
icon={<ExportOutlined />}
onClick={() => { setExportPreselectedCuts([]); setExportOpen(true); }}
>
Export to Campaign
</Button>
<Button
size="small"
icon={<HistoryOutlined />}
@ -297,7 +309,7 @@ export default function CanvassDashboardPage() {
/>
</Card>
<Card title="Volunteer Leaderboard" size="small">
<Card title="Volunteer Leaderboard" size="small" style={{ marginBottom: 12 }}>
<Table
dataSource={volunteers}
columns={volunteerColumns}
@ -308,6 +320,13 @@ export default function CanvassDashboardPage() {
locale={{ emptyText: 'No sessions recorded yet.' }}
/>
</Card>
<CutCampaignAnalyticsCard
onExportCut={(cutId) => {
setExportPreselectedCuts([cutId]);
setExportOpen(true);
}}
/>
</div>
);
@ -338,6 +357,14 @@ export default function CanvassDashboardPage() {
onSelectRoute={setHistoryRoute}
volunteers={volunteers.map((v) => ({ userId: v.userId, name: v.name, email: v.email }))}
/>
{/* Export Contacts Modal */}
<ExportContactsModal
open={exportOpen}
onClose={() => setExportOpen(false)}
cuts={cuts}
preselectedCutIds={exportPreselectedCuts}
/>
</div>
);
}

View File

@ -34,7 +34,6 @@ import {
CheckCircleFilled,
CloseCircleFilled,
MinusCircleFilled,
IdcardOutlined,
HomeOutlined,
} from '@ant-design/icons';
import {
@ -51,15 +50,19 @@ import RequestTrafficChart from '@/components/dashboard/RequestTrafficChart';
import LatencyBandsChart from '@/components/dashboard/LatencyBandsChart';
import ContainerPopover from '@/components/dashboard/ContainerPopover';
import ContainerMemoryChart from '@/components/dashboard/ContainerMemoryChart';
import ActivityFeedCard from '@/components/dashboard/ActivityFeedCard';
import TodayEventsCard from '@/components/dashboard/TodayEventsCard';
import ChatNotifierCard from '@/components/dashboard/ChatNotifierCard';
import { buildServiceUrl } from '@/lib/service-url';
import type {
DashboardSummary, QueueStats, ServicesStatus, ServicesConfig,
SystemInfo, ContainerInfo, WeatherData, ApiMetrics,
TimeSeriesResult, ContainerResource, ContainerResourcesResponse,
ConnectivityStatus,
AppOutletContext,
} from '@/types/api';
const { Text, Title } = Typography;
const { Text } = Typography;
// --- Pulse animation CSS (injected once) ---
const PULSE_STYLE_ID = 'dashboard-pulse-css';
@ -171,6 +174,7 @@ export default function DashboardPage() {
const [apiMetrics, setApiMetrics] = useState<ApiMetrics | null>(null);
const [timeSeries, setTimeSeries] = useState<TimeSeriesResult | null>(null);
const [containerResources, setContainerResources] = useState<ContainerResource[] | null>(null);
const [connectivity, setConnectivity] = useState<ConnectivityStatus | null>(null);
const [loading, setLoading] = useState(true);
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
const [activeView, setActiveView] = useState<'dashboard' | 'homepage'>('dashboard');
@ -208,6 +212,7 @@ export default function DashboardPage() {
api.get<ContainerResourcesResponse>('/dashboard/container-resources').then(({ data }) => {
setContainerResources(data.containers || []);
}).catch(() => {}),
api.get<ConnectivityStatus>('/dashboard/connectivity').then(({ data }) => setConnectivity(data)).catch(() => {}),
);
}
await Promise.allSettled(promises);
@ -280,21 +285,20 @@ export default function DashboardPage() {
<div>
{/* === Welcome Banner === */}
<Card
style={{ marginBottom: 16, background: 'linear-gradient(135deg, #1890ff 0%, #722ed1 100%)', border: 'none' }}
styles={{ body: { padding: screens.md ? '20px 24px' : '16px' } }}
style={{ marginBottom: 12, background: 'linear-gradient(135deg, #1890ff 0%, #722ed1 100%)', border: 'none' }}
styles={{ body: { padding: '10px 16px' } }}
>
<Flex justify="space-between" align="center" wrap="wrap" gap={12}>
<Flex align="center" gap={16} wrap="wrap">
<div>
<Title level={4} style={{ color: '#fff', margin: 0 }}>
Welcome{user?.name ? `, ${user.name}` : ''}
</Title>
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 12 }}>
{lastRefresh && `Updated ${lastRefresh.toLocaleTimeString()}`}
</Text>
</div>
<Flex justify="space-between" align="center" wrap="wrap" gap={8}>
<Flex align="center" gap={12}>
<Text strong style={{ color: '#fff', fontSize: 16, whiteSpace: 'nowrap' }}>
Welcome{user?.name ? `, ${user.name}` : ''}
</Text>
<Text style={{ color: 'rgba(255,255,255,0.55)', fontSize: 12 }}>
{lastRefresh && `Updated ${lastRefresh.toLocaleTimeString()}`}
</Text>
{isSuperAdmin && homepageUrl && (
<Segmented
size="small"
value={activeView}
onChange={(val) => setActiveView(val as 'dashboard' | 'homepage')}
options={[
@ -306,35 +310,29 @@ export default function DashboardPage() {
)}
</Flex>
{activeView === 'dashboard' && (
<Flex gap={6} wrap="wrap" justify="flex-end">
{showInfluence && (
<Button size="small" icon={<PlusOutlined />} onClick={() => navigate('/app/campaigns')}>Campaign</Button>
)}
{showMap && (
<Button size="small" icon={<EnvironmentOutlined />} onClick={() => navigate('/app/map')}>Location</Button>
)}
{showMedia && (
<Button size="small" icon={<UploadOutlined />} onClick={() => navigate('/app/media/library')}>Video</Button>
)}
<Button size="small" icon={<FileTextOutlined />} onClick={() => navigate('/app/pages')}>Page</Button>
<Flex gap={4} wrap="wrap" justify="flex-end">
{showInfluence && <Tooltip title="New Campaign"><Button type="text" icon={<PlusOutlined style={{ color: '#fff', fontSize: 16 }} />} onClick={() => navigate('/app/campaigns')} /></Tooltip>}
{showMap && <Tooltip title="Locations"><Button type="text" icon={<EnvironmentOutlined style={{ color: '#fff', fontSize: 16 }} />} onClick={() => navigate('/app/map')} /></Tooltip>}
{showMedia && <Tooltip title="Videos"><Button type="text" icon={<UploadOutlined style={{ color: '#fff', fontSize: 16 }} />} onClick={() => navigate('/app/media/library')} /></Tooltip>}
<Tooltip title="Pages"><Button type="text" icon={<FileTextOutlined style={{ color: '#fff', fontSize: 16 }} />} onClick={() => navigate('/app/pages')} /></Tooltip>
{isSuperAdmin && (
<>
<Button size="small" icon={<BarChartOutlined />} onClick={() => navigate('/app/observability')}>Monitoring</Button>
<Button size="small" icon={<CloudServerOutlined />} onClick={() => navigate('/app/tunnel')}>Tunnel</Button>
<Button size="small" icon={<DatabaseOutlined />} onClick={() => navigate('/app/services/nocodb')}>NocoDB</Button>
<Button size="small" icon={<BranchesOutlined />} onClick={() => navigate('/app/services/n8n')}>Workflows</Button>
<Button size="small" icon={<GlobalOutlined />} onClick={() => navigate('/app/services/gitea')}>Git</Button>
<Button size="small" icon={<CodeOutlined />} onClick={() => navigate('/app/code')}>Code</Button>
<Button size="small" icon={<BookOutlined />} onClick={() => navigate('/app/docs')}>Docs</Button>
<Button size="small" icon={<QrcodeOutlined />} onClick={() => navigate('/app/services/miniqr')}>QR</Button>
<Button size="small" icon={<DashboardOutlined />} onClick={() => navigate('/app/map/data-quality')}>Data Quality</Button>
<Tooltip title="Monitoring"><Button type="text" icon={<BarChartOutlined style={{ color: '#fff', fontSize: 16 }} />} onClick={() => navigate('/app/observability')} /></Tooltip>
<Tooltip title="Tunnel"><Button type="text" icon={<CloudServerOutlined style={{ color: '#fff', fontSize: 16 }} />} onClick={() => navigate('/app/tunnel')} /></Tooltip>
<Tooltip title="NocoDB"><Button type="text" icon={<DatabaseOutlined style={{ color: '#fff', fontSize: 16 }} />} onClick={() => navigate('/app/services/nocodb')} /></Tooltip>
<Tooltip title="Workflows"><Button type="text" icon={<BranchesOutlined style={{ color: '#fff', fontSize: 16 }} />} onClick={() => navigate('/app/services/n8n')} /></Tooltip>
<Tooltip title="Git"><Button type="text" icon={<GlobalOutlined style={{ color: '#fff', fontSize: 16 }} />} onClick={() => navigate('/app/services/gitea')} /></Tooltip>
<Tooltip title="Code"><Button type="text" icon={<CodeOutlined style={{ color: '#fff', fontSize: 16 }} />} onClick={() => navigate('/app/code')} /></Tooltip>
<Tooltip title="Docs"><Button type="text" icon={<BookOutlined style={{ color: '#fff', fontSize: 16 }} />} onClick={() => navigate('/app/docs')} /></Tooltip>
<Tooltip title="QR"><Button type="text" icon={<QrcodeOutlined style={{ color: '#fff', fontSize: 16 }} />} onClick={() => navigate('/app/services/miniqr')} /></Tooltip>
<Tooltip title="Data Quality"><Button type="text" icon={<DashboardOutlined style={{ color: '#fff', fontSize: 16 }} />} onClick={() => navigate('/app/map/data-quality')} /></Tooltip>
</>
)}
<Button size="small" icon={<ReloadOutlined spin={loading} />} onClick={handleRefresh}>Refresh</Button>
<Tooltip title="Refresh"><Button type="text" icon={<ReloadOutlined spin={loading} style={{ color: '#fff', fontSize: 16 }} />} onClick={handleRefresh} /></Tooltip>
</Flex>
)}
{activeView === 'homepage' && (
<Button size="small" icon={<ReloadOutlined spin={loading} />} onClick={handleRefresh}>Refresh</Button>
<Tooltip title="Refresh"><Button type="text" icon={<ReloadOutlined spin={loading} style={{ color: '#fff', fontSize: 16 }} />} onClick={handleRefresh} /></Tooltip>
)}
</Flex>
</Card>
@ -400,107 +398,130 @@ export default function DashboardPage() {
);
})()}
{/* === Weather + Key Metrics Row === */}
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
{weather && (
<Col xs={12} sm={12} lg={4}>
<Card size="small" style={{ height: '100%', borderTop: '3px solid #1890ff' }} styles={{ body: { padding: '12px 16px' } }}>
<div style={{ fontSize: 28, lineHeight: 1 }}>{getWeatherIcon(weather.weatherCode, weather.isDay)}</div>
<Text strong style={{ fontSize: 22 }}>{Math.round(weather.temperature)}{'°C'}</Text>
<br />
<Text type="secondary" style={{ fontSize: 11 }}>
{weather.weatherDescription}
</Text>
<br />
<Text type="secondary" style={{ fontSize: 11 }}>
{'Feels ' + Math.round(weather.apparentTemperature) + '° · ' + weather.humidity + '% · ' + Math.round(weather.windSpeed) + ' km/h'}
</Text>
</Card>
</Col>
)}
{/* === Status Bar (weather + stats + pending actions + connectivity) === */}
{summary && (
<Card size="small" style={{ marginBottom: 12 }} styles={{ body: { padding: '8px 16px' } }}>
<Flex justify="space-between" align="center" wrap="wrap" gap={8}>
<Flex gap={0} wrap="wrap" align="center">
{weather && (
<Flex align="center" gap={6} style={{ padding: '0 12px 0 0', borderRight: '1px solid rgba(255,255,255,0.08)' }}>
<span style={{ fontSize: 20 }}>{getWeatherIcon(weather.weatherCode, weather.isDay)}</span>
<Text strong style={{ fontSize: 14 }}>{Math.round(weather.temperature)}°C</Text>
<Text type="secondary" style={{ fontSize: 12 }}>{weather.weatherDescription}</Text>
</Flex>
)}
{/* Quick stat chips */}
<QuickStat icon={<TeamOutlined />} color="#1890ff" value={summary.users.total} label="Users" onClick={() => navigate('/app/users')} />
{showInfluence && <QuickStat icon={<SendOutlined />} color="#52c41a" value={summary.campaigns.active} label={`of ${summary.campaigns.total}`} onClick={() => navigate('/app/campaigns')} />}
{showMap && <QuickStat icon={<EnvironmentOutlined />} color="#722ed1" value={summary.locations.total.toLocaleString()} label={`${geocodePct}% geo`} onClick={() => navigate('/app/map')} />}
{showInfluence && <QuickStat icon={<MailOutlined />} color="#faad14" value={summary.emails.sent} label="sent" onClick={() => navigate('/app/email-queue')} />}
{showMedia && <QuickStat icon={<VideoCameraOutlined />} color="#13c2c2" value={summary.videos.published} label={`of ${summary.videos.total}`} onClick={() => navigate('/app/media/library')} />}
{showMap && <QuickStat icon={<CalendarOutlined />} color="#eb2f96" value={summary.shifts.upcoming} label={`${summary.shifts.open} open`} onClick={() => navigate('/app/map/shifts')} />}
{/* Pending action tags */}
{summary.responses.pending > 0 && (
<Tag color="orange" style={{ cursor: 'pointer', margin: '0 0 0 4px' }} onClick={() => navigate('/app/responses')}>
{summary.responses.pending} pending
</Tag>
)}
{summary.locations.total > 0 && summary.locations.total - summary.locations.geocoded > 0 && (
<Tag color="purple" style={{ cursor: 'pointer', margin: '0 0 0 4px' }} onClick={() => navigate('/app/map')}>
{summary.locations.total - summary.locations.geocoded} ungeocoded
</Tag>
)}
{summary.emails.queued > 0 && (
<Tag color="blue" style={{ cursor: 'pointer', margin: '0 0 0 4px' }} onClick={() => navigate('/app/email-queue')}>
{summary.emails.queued} queued
</Tag>
)}
{summary.campaignModeration.pendingReview > 0 && (
<Tag color="gold" style={{ cursor: 'pointer', margin: '0 0 0 4px' }} onClick={() => navigate('/app/campaign-moderation')}>
{summary.campaignModeration.pendingReview} review
</Tag>
)}
</Flex>
{isSuperAdmin && connectivity && (
<Flex gap={6} align="center" style={{ flexShrink: 0 }}>
<ConnectivityDot label="SMTP" online={connectivity.smtp} />
<ConnectivityDot label="Listmonk" online={connectivity.listmonk} />
<ConnectivityDot label="Chat" online={connectivity.rocketchat} />
<ConnectivityDot label="Events" online={connectivity.gancio} />
</Flex>
)}
</Flex>
</Card>
)}
<Col xs={12} sm={12} lg={4}>
<StatCard title="Users" value={summary?.users.total} subtitle={summary ? `${summary.users.active} active` : ''} icon={<TeamOutlined />} color="#1890ff" onClick={() => navigate('/app/users')} />
</Col>
{/* === Email Queue Widget (shown if queue has items) === */}
{queue && (queue.waiting > 0 || queue.active > 0 || queue.failed > 0) && (
<Card
size="small"
style={{ marginBottom: 12, borderLeft: `4px solid ${queue.failed > 0 ? '#ff4d4f' : queue.paused ? '#faad14' : '#1890ff'}` }}
styles={{ body: { padding: '6px 16px' } }}
>
<Flex justify="space-between" align="center" wrap="wrap" gap={8}>
<Flex gap={16} align="center">
<MailOutlined style={{ fontSize: 16, color: '#1890ff' }} />
<div>
<Text strong style={{ fontSize: 13 }}>Email Queue</Text>
{queue.paused && <Tag color="red" style={{ marginLeft: 8 }}>Paused</Tag>}
</div>
<Space size={16}>
<Statistic title={<Text style={{ fontSize: 10 }}>Waiting</Text>} value={queue.waiting} valueStyle={{ fontSize: 18 }} />
<Statistic title={<Text style={{ fontSize: 10 }}>Active</Text>} value={queue.active} valueStyle={{ fontSize: 18, color: '#1890ff' }} />
{queue.failed > 0 && (
<Statistic title={<Text style={{ fontSize: 10 }}>Failed</Text>} value={queue.failed} valueStyle={{ fontSize: 18, color: '#ff4d4f' }} />
)}
</Space>
</Flex>
<Button size="small" type="link" onClick={() => navigate('/app/email-queue')}>
Manage Queue
</Button>
</Flex>
</Card>
)}
{/* === Module Overview Row (3 columns) === */}
<Row gutter={[12, 12]} style={{ marginBottom: 12 }}>
{showInfluence && (
<Col xs={12} sm={12} lg={4}>
<StatCard title="Campaigns" value={summary?.campaigns.active} subtitle={summary ? `of ${summary.campaigns.total} total` : ''} icon={<SendOutlined />} color="#52c41a" onClick={() => navigate('/app/campaigns')} />
</Col>
)}
{showMap && (
<Col xs={12} sm={12} lg={4}>
<StatCard title="Locations" value={summary?.locations.total} subtitle={summary ? `${geocodePct}% geocoded` : ''} icon={<EnvironmentOutlined />} color="#722ed1" onClick={() => navigate('/app/map')} />
</Col>
)}
{showInfluence && (
<Col xs={12} sm={12} lg={4}>
<StatCard title="Emails Sent" value={summary?.emails.sent} subtitle={summary?.emails.failed ? `${summary.emails.failed} failed` : '0 failed'} icon={<MailOutlined />} color="#faad14" onClick={() => navigate('/app/email-queue')} />
</Col>
)}
{showMedia && (
<Col xs={12} sm={12} lg={4}>
<StatCard title="Videos" value={summary?.videos.published} subtitle={summary ? `of ${summary.videos.total} total` : ''} icon={<VideoCameraOutlined />} color="#13c2c2" onClick={() => navigate('/app/media/library')} />
</Col>
)}
{showMap && (
<Col xs={12} sm={12} lg={4}>
<StatCard title="Shifts" value={summary?.shifts.upcoming} subtitle={summary ? `${summary.shifts.open} open` : ''} icon={<CalendarOutlined />} color="#eb2f96" onClick={() => navigate('/app/map/shifts')} />
</Col>
)}
</Row>
{/* === Module Overview Row === */}
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
{showInfluence && (
<Col xs={24} lg={12}>
<Col xs={24} lg={8}>
<Card
title={<><SendOutlined style={{ marginRight: 6 }} />Influence</>}
title={
<Flex align="center" gap={6}>
<SendOutlined />
<span>Influence</span>
{summary && <Text type="secondary" style={{ fontSize: 12, fontWeight: 400 }}>{summary.campaigns.active} active / {summary.campaigns.total}</Text>}
</Flex>
}
size="small"
extra={<Button type="link" size="small" onClick={() => navigate('/app/campaigns')}>View</Button>}
style={{ height: '100%' }}
>
{summary && (
<Flex gap={12} align="flex-start">
<div style={{ flex: 1 }}>
<Space direction="vertical" style={{ width: '100%' }} size={4}>
<Flex gap={4} wrap="wrap">
<Text strong>Campaigns:</Text>
<Tag color="green">{summary.campaigns.active} Active</Tag>
<Tag>{summary.campaigns.draft} Draft</Tag>
{summary.campaigns.paused > 0 && <Tag color="orange">{summary.campaigns.paused} Paused</Tag>}
<Flex gap={8} align="flex-start">
<Space direction="vertical" style={{ width: '100%', flex: 1 }} size={4}>
<Flex gap={4} wrap="wrap">
<Tag color="green">{summary.campaigns.active} Active</Tag>
<Tag>{summary.campaigns.draft} Draft</Tag>
{summary.campaigns.paused > 0 && <Tag color="orange">{summary.campaigns.paused} Paused</Tag>}
</Flex>
<Flex justify="space-between">
<Text>Responses: <Text strong>{summary.responses.total}</Text></Text>
{summary.responses.pending > 0 && <Tag color="orange" style={{ margin: 0 }}>{summary.responses.pending} pending</Tag>}
</Flex>
<Flex justify="space-between">
<Text>Emails: <Text strong>{summary.emails.sent}</Text> sent</Text>
{summary.emails.failed > 0 && <Text type="danger">{summary.emails.failed} failed</Text>}
</Flex>
{queue && (
<Flex justify="space-between">
<Text>Queue: <Text strong>{queue.waiting}</Text> waiting</Text>
{queue.paused && <Tag color="red" style={{ margin: 0 }}>Paused</Tag>}
</Flex>
<div>
<Text strong>Responses: </Text>
<Text>{summary.responses.total} total</Text>
{summary.responses.pending > 0 && <Tag color="orange" style={{ marginLeft: 6 }}>{summary.responses.pending} pending</Tag>}
</div>
<div>
<Text strong>Queue: </Text>
{queue ? (
<>
<Text>{queue.waiting} waiting, {queue.active} active</Text>
{queue.paused && <Tag color="red" style={{ marginLeft: 6 }}>Paused</Tag>}
</>
) : <Text type="secondary">unavailable</Text>}
</div>
{summary.campaignModeration.pendingReview > 0 && (
<div>
<Button type="link" size="small" style={{ padding: 0 }} onClick={() => navigate('/app/campaign-moderation')}>
<Tag color="orange">{summary.campaignModeration.pendingReview} pending review</Tag>
</Button>
</div>
)}
</Space>
</div>
{/* Campaign status donut */}
{summary.campaigns.total > 0 && screens.sm && (
<div style={{ width: 100, flexShrink: 0 }}>
<MiniDonutChart data={campaignDonutData} height={100} innerRadius={24} outerRadius={42} />
)}
</Space>
{summary.campaigns.total > 0 && screens.md && (
<div style={{ width: 80, flexShrink: 0 }}>
<MiniDonutChart data={campaignDonutData} height={80} innerRadius={20} outerRadius={34} />
</div>
)}
</Flex>
@ -510,9 +531,15 @@ export default function DashboardPage() {
)}
{showMap && (
<Col xs={24} lg={12}>
<Col xs={24} lg={8}>
<Card
title={<><CompassOutlined style={{ marginRight: 6 }} />Map & Canvassing</>}
title={
<Flex align="center" gap={6}>
<CompassOutlined />
<span>Map</span>
{summary && <Text type="secondary" style={{ fontSize: 12, fontWeight: 400 }}>{summary.locations.total.toLocaleString()} locations</Text>}
</Flex>
}
size="small"
extra={<Button type="link" size="small" onClick={() => navigate('/app/map')}>View</Button>}
style={{ height: '100%' }}
@ -520,84 +547,90 @@ export default function DashboardPage() {
{summary && (
<Space direction="vertical" style={{ width: '100%' }} size={4}>
<Flex align="center" gap={8}>
<Text strong>Geocoding:</Text>
<Text>Geocoded:</Text>
<Progress percent={geocodePct} size="small" style={{ flex: 1 }} />
<Text type="secondary" style={{ fontSize: 12 }}>{summary.locations.geocoded.toLocaleString()}/{summary.locations.total.toLocaleString()}</Text>
</Flex>
<div>
<Text strong>Addresses: </Text>
<Text>{summary.locations.addresses.toLocaleString()}</Text>
<Text type="secondary" style={{ marginLeft: 8 }}><ScissorOutlined /> {summary.cuts.total} cuts</Text>
</div>
<div>
<Text strong>Canvassing: </Text>
<Text>{summary.canvass.totalVisits} visits</Text>
{summary.canvass.activeSessions > 0 && <Tag color="green" style={{ marginLeft: 6 }}>{summary.canvass.activeSessions} active</Tag>}
</div>
<Flex justify="space-between">
<Text>Addresses: <Text strong>{summary.locations.addresses.toLocaleString()}</Text></Text>
<Text type="secondary"><ScissorOutlined /> {summary.cuts.total} cuts</Text>
</Flex>
<Flex justify="space-between">
<Text>Canvassing: <Text strong>{summary.canvass.totalVisits}</Text> visits</Text>
{summary.canvass.activeSessions > 0 && <Tag color="green" style={{ margin: 0 }}>{summary.canvass.activeSessions} active</Tag>}
</Flex>
<Flex justify="space-between">
<Text>Shifts: <Text strong>{summary.shifts.upcoming}</Text> upcoming</Text>
<Text type="secondary">{summary.shifts.open} open</Text>
</Flex>
</Space>
)}
</Card>
</Col>
)}
<Col xs={24} lg={12}>
<Col xs={24} lg={8}>
<Card
title={<><FileTextOutlined style={{ marginRight: 6 }} />Content</>}
size="small"
extra={<Button type="link" size="small" onClick={() => navigate('/app/pages')}>View</Button>}
style={{ height: '100%' }}
>
{summary && (
<Flex gap={16} wrap="wrap" align="center">
<div>
<Text strong>Pages: </Text>
<Text>{summary.pages.published} published</Text>
<Text type="secondary"> / {summary.pages.total}</Text>
</div>
<div>
<Text strong>Templates: </Text>
<Text>{summary.emailTemplates.total}</Text>
</div>
{showInfluence && (
<div>
<Text strong>Rep Cache: </Text>
<Text>{summary.representatives.totalCached}</Text>
</div>
)}
{showMedia && (
<div>
<Text strong>Videos: </Text>
<Text>{summary.videos.published} published / {summary.videos.total}</Text>
</div>
)}
title={
<Flex align="center" gap={6}>
<TeamOutlined />
<span>Users & Content</span>
{summary && <Text type="secondary" style={{ fontSize: 12, fontWeight: 400 }}>{summary.users.total} users</Text>}
</Flex>
)}
</Card>
</Col>
<Col xs={24} lg={12}>
<Card
title={<><IdcardOutlined style={{ marginRight: 6 }} />Users</>}
}
size="small"
extra={<Button type="link" size="small" onClick={() => navigate('/app/users')}>Manage</Button>}
style={{ height: '100%' }}
>
{summary && (
<Flex gap={4} wrap="wrap">
{Object.entries(summary.users.byRole)
.filter(([, count]) => count > 0)
.map(([role, count]) => (
<Tag key={role} color={ROLE_COLORS[role] || 'default'}>
{ROLE_LABELS[role] || role}: {count}
</Tag>
))}
{summary.users.suspended > 0 && <Tag color="volcano">Suspended: {summary.users.suspended}</Tag>}
</Flex>
<Space direction="vertical" style={{ width: '100%' }} size={4}>
<Flex gap={4} wrap="wrap">
{Object.entries(summary.users.byRole)
.filter(([, count]) => count > 0)
.map(([role, count]) => (
<Tag key={role} color={ROLE_COLORS[role] || 'default'} style={{ margin: 0 }}>
{ROLE_LABELS[role] || role}: {count}
</Tag>
))}
{summary.users.suspended > 0 && <Tag color="volcano" style={{ margin: 0 }}>Suspended: {summary.users.suspended}</Tag>}
</Flex>
<Flex justify="space-between">
<Text>Pages: <Text strong>{summary.pages.published}</Text> published</Text>
<Text type="secondary">/ {summary.pages.total}</Text>
</Flex>
<Flex justify="space-between">
<Text>Templates: <Text strong>{summary.emailTemplates.total}</Text></Text>
{showInfluence && <Text type="secondary">Reps: {summary.representatives.totalCached}</Text>}
</Flex>
{showMedia && (
<Flex justify="space-between">
<Text>Videos: <Text strong>{summary.videos.published}</Text> published</Text>
<Text type="secondary">/ {summary.videos.total}</Text>
</Flex>
)}
</Space>
)}
</Card>
</Col>
</Row>
{/* === Activity Feed + Events + Chat === */}
<Row gutter={[12, 12]} style={{ marginBottom: 12 }}>
<Col xs={24} lg={12}>
<ActivityFeedCard />
</Col>
<Col xs={24} lg={12}>
<Row gutter={[12, 12]}>
<Col xs={24}>
<TodayEventsCard />
</Col>
<Col xs={24}>
<ChatNotifierCard />
</Col>
</Row>
</Col>
</Row>
{/* === System + Docker Section (SUPER_ADMIN only) === */}
{isSuperAdmin && (
<>
@ -876,34 +909,6 @@ function MiniSystemChart({ timeSeries }: { timeSeries: TimeSeriesResult }) {
);
}
// --- Stat Card Component ---
function StatCard({ title, value, subtitle, icon, color, onClick }: {
title: string;
value?: number | null;
subtitle: string;
icon: React.ReactNode;
color: string;
onClick: () => void;
}) {
return (
<Card
hoverable
onClick={onClick}
size="small"
style={{ borderTop: `3px solid ${color}`, cursor: 'pointer', height: '100%' }}
styles={{ body: { padding: '12px 16px' } }}
>
<Statistic
title={<Text style={{ fontSize: 12 }}>{title}</Text>}
value={value ?? '--'}
prefix={icon}
valueStyle={{ color, fontSize: 22 }}
/>
<Text type="secondary" style={{ fontSize: 11 }}>{subtitle}</Text>
</Card>
);
}
// --- Service Badge Component (with pulse animation) ---
@ -927,6 +932,47 @@ const SERVICE_ICONS: Record<string, React.ReactNode> = {
homepage: <HomeOutlined />,
};
// --- Quick Stat chip (for status bar) ---
function QuickStat({ icon, color, value, label, onClick }: {
icon: React.ReactNode;
color: string;
value: string | number;
label: string;
onClick: () => void;
}) {
return (
<Flex
align="center"
gap={5}
onClick={onClick}
style={{
padding: '2px 10px',
cursor: 'pointer',
borderRight: '1px solid rgba(255,255,255,0.06)',
}}
>
<span style={{ color, fontSize: 14 }}>{icon}</span>
<Text strong style={{ fontSize: 14 }}>{value}</Text>
<Text type="secondary" style={{ fontSize: 12 }}>{label}</Text>
</Flex>
);
}
function ConnectivityDot({ label, online }: { label: string; online: boolean }) {
return (
<Tooltip title={`${label}: ${online ? 'Connected' : 'Offline'}`}>
<Flex gap={3} align="center">
<div style={{
width: 8, height: 8, borderRadius: '50%',
background: online ? '#52c41a' : '#ff4d4f',
}} />
<Text style={{ fontSize: 11 }}>{label}</Text>
</Flex>
</Tooltip>
);
}
function ServiceBadge({ name, online, icon }: {
name: string;
online?: boolean;

View File

@ -0,0 +1,127 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useOutletContext } from 'react-router-dom';
import { Button, Space, Badge, Spin, Grid, Result } from 'antd';
import { ReloadOutlined, LinkOutlined, CalendarOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import type { AppOutletContext } from '@/components/AppLayout';
import type { ServicesStatus, ServicesConfig } from '@/types/api';
import { buildServiceUrl } from '@/lib/service-url';
export default function GancioPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [online, setOnline] = useState<boolean | null>(null);
const [config, setConfig] = useState<ServicesConfig | null>(null);
const [loading, setLoading] = useState(true);
const fetchStatus = useCallback(async () => {
try {
const [statusRes, configRes] = await Promise.all([
api.get<ServicesStatus>('/services/status'),
api.get<ServicesConfig>('/services/config'),
]);
setOnline(statusRes.data.gancio.online);
setConfig(configRes.data);
} catch {
setOnline(false);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchStatus();
}, [fetchStatus]);
const serviceUrl = config
? buildServiceUrl(config.gancioSubdomain, config.domain, config.gancioPort)
: null;
const handleRefresh = useCallback(() => {
fetchStatus();
}, [fetchStatus]);
const headerActions = useMemo(() => (
<Space>
<Badge
status={online === null ? 'processing' : online ? 'success' : 'error'}
text={online === null ? 'Checking...' : online ? 'Online' : 'Offline'}
/>
<Button
icon={<ReloadOutlined />}
onClick={handleRefresh}
size="small"
>
Refresh
</Button>
{serviceUrl && (
<Button
icon={<LinkOutlined />}
href={serviceUrl}
target="_blank"
size="small"
>
Open in New Tab
</Button>
)}
</Space>
), [online, handleRefresh, serviceUrl]);
useEffect(() => {
setPageHeader({
title: 'Events',
actions: headerActions,
fullBleed: true
});
return () => setPageHeader(null);
}, [setPageHeader, headerActions]);
if (isMobile) {
return (
<Result
status="info"
title="Desktop Required"
subTitle="Gancio requires a desktop browser with a larger screen for optimal experience."
icon={<CalendarOutlined style={{ fontSize: 48 }} />}
/>
);
}
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 80 }}>
<Spin size="large" />
</div>
);
}
if (!online || !serviceUrl) {
return (
<Result
status="error"
title="Gancio Unavailable"
subTitle="Gancio is not running or could not be reached. Check that the Gancio container is started."
extra={
<Button type="primary" onClick={handleRefresh}>
Retry
</Button>
}
/>
);
}
return (
<iframe
src={serviceUrl}
style={{
width: '100%',
height: 'calc(100vh - 64px)',
border: 'none',
display: 'block',
}}
title="Gancio Events"
/>
);
}

View File

@ -44,6 +44,7 @@ export default function ListmonkPage() {
const [status, setStatus] = useState<ListmonkStatus | null>(null);
const [stats, setStats] = useState<ListmonkStats | null>(null);
const [config, setConfig] = useState<ServicesConfig | null>(null);
const [eventSyncStats, setEventSyncStats] = useState<{ enabled: boolean; lastSyncAt: string | null; todaySyncCount: number } | null>(null);
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState<Record<string, boolean>>({});
const [iframeSrc, setIframeSrc] = useState<string | null>(null);
@ -79,11 +80,20 @@ export default function ListmonkPage() {
}
}, []);
const fetchEventSyncStats = useCallback(async () => {
try {
const res = await api.get<{ enabled: boolean; lastSyncAt: string | null; todaySyncCount: number }>('/listmonk/event-sync-stats');
setEventSyncStats(res.data);
} catch {
// Event sync stats fetch failed — leave null
}
}, []);
const fetchAll = useCallback(async () => {
setLoading(true);
await Promise.all([fetchStatus(), fetchStats(), fetchConfig()]);
await Promise.all([fetchStatus(), fetchStats(), fetchConfig(), fetchEventSyncStats()]);
setLoading(false);
}, [fetchStatus, fetchStats, fetchConfig]);
}, [fetchStatus, fetchStats, fetchConfig, fetchEventSyncStats]);
useEffect(() => {
fetchAll();
@ -350,6 +360,29 @@ export default function ListmonkPage() {
/>
</Card>
<Card title="Event-Driven Sync" size="small" style={{ marginTop: 16 }}>
<Descriptions column={1} size="small">
<Descriptions.Item label="Status">
<Badge
status={eventSyncStats?.enabled ? 'success' : 'default'}
text={eventSyncStats?.enabled ? 'Active' : 'Disabled'}
/>
</Descriptions.Item>
<Descriptions.Item label="Last Event Sync">
{eventSyncStats?.lastSyncAt ? dayjs(eventSyncStats.lastSyncAt).fromNow() : 'No events yet'}
</Descriptions.Item>
<Descriptions.Item label="Today&apos;s Syncs">
{eventSyncStats?.todaySyncCount ?? 0}
</Descriptions.Item>
</Descriptions>
<Alert
type="info"
message="Shift signups, canvass completions, and campaign emails are automatically synced to Listmonk when LISTMONK_SYNC_ENABLED=true."
showIcon
style={{ marginTop: 12 }}
/>
</Card>
<Collapse
style={{ marginTop: 16 }}
items={[

View File

@ -1,12 +1,14 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useOutletContext } from 'react-router-dom';
import { Button, Space, Badge, Spin, Grid, Result } from 'antd';
import { Button, Space, Badge, Spin, Grid, Result, Alert } from 'antd';
import { ReloadOutlined, LinkOutlined, DatabaseOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import type { AppOutletContext } from '@/components/AppLayout';
import type { ServicesStatus, ServicesConfig } from '@/types/api';
import { buildServiceUrl } from '@/lib/service-url';
const BANNER_DISMISSED_KEY = 'nocodb-auth-banner-dismissed';
export default function NocoDBPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
const screens = Grid.useBreakpoint();
@ -15,6 +17,9 @@ export default function NocoDBPage() {
const [online, setOnline] = useState<boolean | null>(null);
const [config, setConfig] = useState<ServicesConfig | null>(null);
const [loading, setLoading] = useState(true);
const [bannerDismissed, setBannerDismissed] = useState(
() => localStorage.getItem(BANNER_DISMISSED_KEY) === 'true'
);
const fetchStatus = useCallback(async () => {
try {
@ -109,15 +114,38 @@ export default function NocoDBPage() {
}
return (
<iframe
src={serviceUrl}
style={{
width: '100%',
height: 'calc(100vh - 64px)',
border: 'none',
display: 'block',
}}
title="NocoDB"
/>
<div style={{ height: 'calc(100vh - 64px)', display: 'flex', flexDirection: 'column' }}>
{!bannerDismissed && (
<Alert
message={
<>
If the database browser appears blank, you may need to{' '}
<a href={serviceUrl} target="_blank" rel="noopener noreferrer">
sign in to NocoDB in a new tab
</a>{' '}
first, then refresh this page.
</>
}
type="info"
showIcon
closable
onClose={() => {
setBannerDismissed(true);
localStorage.setItem(BANNER_DISMISSED_KEY, 'true');
}}
style={{ borderRadius: 0, flexShrink: 0 }}
/>
)}
<iframe
src={serviceUrl}
style={{
width: '100%',
flex: 1,
border: 'none',
display: 'block',
}}
title="NocoDB"
/>
</div>
);
}

View File

@ -0,0 +1,208 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useOutletContext } from 'react-router-dom';
import { Button, Space, Badge, Spin, Grid, Result } from 'antd';
import { ReloadOutlined, LinkOutlined, MessageOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import type { AppOutletContext } from '@/components/AppLayout';
import type { ServicesConfig } from '@/types/api';
import { buildServiceUrl } from '@/lib/service-url';
interface RCConfig {
enabled: boolean;
embedPort: number;
subdomain: string;
domain: string;
}
interface RCAuthResponse {
authToken: string;
rcUserId: string;
}
export default function RocketChatPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [online, setOnline] = useState<boolean | null>(null);
const [config, setConfig] = useState<ServicesConfig | null>(null);
const [rcConfig, setRcConfig] = useState<RCConfig | null>(null);
const [authToken, setAuthToken] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [authError, setAuthError] = useState<string | null>(null);
const iframeRef = useRef<HTMLIFrameElement>(null);
const fetchStatus = useCallback(async () => {
setLoading(true);
setAuthError(null);
try {
const [statusRes, configRes, rcConfigRes] = await Promise.all([
api.get<{ online: boolean; enabled: boolean }>('/rocketchat/status'),
api.get<ServicesConfig>('/services/config'),
api.get<RCConfig>('/rocketchat/config'),
]);
setOnline(statusRes.data.online);
setConfig(configRes.data);
setRcConfig(rcConfigRes.data);
// If online, get auth token for SSO
if (statusRes.data.online && statusRes.data.enabled) {
try {
const authRes = await api.post<RCAuthResponse>('/rocketchat/auth');
setAuthToken(authRes.data.authToken);
} catch (err) {
setAuthError('Failed to authenticate with Rocket.Chat');
}
}
} catch {
setOnline(false);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchStatus();
}, [fetchStatus]);
// Inject auth token into iframe via postMessage when iframe loads.
// RC's iframe integration listener may not be ready immediately after the
// login page renders, so we retry a few times with a delay.
const retryTimers = useRef<ReturnType<typeof setTimeout>[]>([]);
const handleIframeLoad = useCallback(() => {
// Clear any pending retries from a previous load
retryTimers.current.forEach(clearTimeout);
retryTimers.current = [];
if (!authToken || !iframeRef.current?.contentWindow) return;
const sendToken = () => {
if (!iframeRef.current?.contentWindow) return;
iframeRef.current.contentWindow.postMessage(
{ event: 'login-with-token', loginToken: authToken },
'*',
);
};
// Send immediately, then retry after short delays in case the
// RC login page hasn't registered its listener yet
sendToken();
retryTimers.current.push(setTimeout(sendToken, 1000));
retryTimers.current.push(setTimeout(sendToken, 3000));
}, [authToken]);
// Cleanup timers on unmount
useEffect(() => {
return () => retryTimers.current.forEach(clearTimeout);
}, []);
const serviceUrl = useMemo(() => {
if (!config || !rcConfig) return null;
return buildServiceUrl(rcConfig.subdomain, rcConfig.domain, rcConfig.embedPort);
}, [config, rcConfig]);
const headerActions = useMemo(() => (
<Space>
<Badge
status={online === null ? 'processing' : online ? 'success' : 'error'}
text={online === null ? 'Checking...' : online ? 'Online' : 'Offline'}
/>
<Button icon={<ReloadOutlined />} onClick={fetchStatus} size="small">
Refresh
</Button>
{serviceUrl && (
<Button icon={<LinkOutlined />} href={serviceUrl} target="_blank" size="small">
Open in New Tab
</Button>
)}
</Space>
), [online, fetchStatus, serviceUrl]);
useEffect(() => {
setPageHeader({
title: 'Team Chat',
actions: headerActions,
fullBleed: true,
});
return () => setPageHeader(null);
}, [setPageHeader, headerActions]);
if (isMobile) {
return (
<Result
status="info"
title="Desktop Required"
subTitle="Rocket.Chat requires a desktop browser for the best experience."
icon={<MessageOutlined style={{ fontSize: 48 }} />}
/>
);
}
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 80 }}>
<Spin size="large" />
</div>
);
}
if (!rcConfig?.enabled) {
return (
<Result
status="info"
title="Chat Not Enabled"
subTitle="Team chat is not enabled. Enable it in Settings → Feature Toggles."
/>
);
}
if (!online || !serviceUrl) {
return (
<Result
status="error"
title="Rocket.Chat Unavailable"
subTitle="Rocket.Chat is not running or could not be reached. Check that the rocketchat and mongodb-rocketchat containers are started."
extra={
<Button type="primary" onClick={fetchStatus}>
Retry
</Button>
}
/>
);
}
if (authError) {
return (
<Result
status="warning"
title="Authentication Failed"
subTitle={authError}
extra={
<Button type="primary" onClick={fetchStatus}>
Retry
</Button>
}
/>
);
}
// Load full RC UI (with sidebar) — layout=embedded hides the channel sidebar
const iframeSrc = serviceUrl;
return (
<iframe
ref={iframeRef}
src={iframeSrc}
onLoad={handleIframeLoad}
style={{
width: '100%',
height: 'calc(100vh - 64px)',
border: 'none',
display: 'block',
}}
title="Rocket.Chat Team Chat"
allow="microphone; camera"
/>
);
}

View File

@ -501,6 +501,89 @@ export default function SettingsPage() {
<Form.Item label="Enable Gallery Ads" name="enableGalleryAds" valuePropName="checked" extra="Promotional cards inserted into the public video gallery">
<Switch />
</Form.Item>
<Form.Item label="Enable Team Chat" name="enableChat" valuePropName="checked" extra="Rocket.Chat integration for team coordination (requires rocketchat container)">
<Switch />
</Form.Item>
<Form.Item label="Enable Events" name="enableEvents" valuePropName="checked" extra="Gancio event calendar integration (requires gancio container)">
<Switch />
</Form.Item>
</div>
),
},
{
key: 'notifications',
label: 'Notifications',
children: (
<div style={{ maxWidth: 600 }}>
<Alert
type="info"
message="Control which automated email notifications are sent. Disabling a notification stops future emails but does not affect existing queued jobs."
showIcon
style={{ marginBottom: 24 }}
/>
<Text strong style={{ fontSize: 15 }}>Admin Alerts</Text>
<Divider style={{ margin: '12px 0' }} />
<Form.Item
label="New Shift Signup"
name="notifyAdminShiftSignup"
valuePropName="checked"
extra="Notify admins when a volunteer signs up for a shift"
>
<Switch />
</Form.Item>
<Form.Item
label="Response Wall Submission"
name="notifyAdminResponseSubmitted"
valuePropName="checked"
extra="Notify admins when a new response is submitted for moderation"
>
<Switch />
</Form.Item>
<Form.Item
label="Sign Request"
name="notifyAdminSignRequested"
valuePropName="checked"
extra="Notify admins when a resident requests a yard sign during canvassing"
>
<Switch />
</Form.Item>
<Form.Item
label="Shift Cancellation"
name="notifyAdminShiftCancellation"
valuePropName="checked"
extra="Notify admins when a volunteer cancels their shift signup"
>
<Switch />
</Form.Item>
<Divider />
<Text strong style={{ fontSize: 15 }}>Volunteer Emails</Text>
<Divider style={{ margin: '12px 0' }} />
<Form.Item
label="Canvass Session Summary"
name="notifyVolunteerSessionSummary"
valuePropName="checked"
extra="Send volunteers a summary email after completing a canvassing session"
>
<Switch />
</Form.Item>
<Form.Item
label="Signup Cancellation"
name="notifyVolunteerCancellation"
valuePropName="checked"
extra="Send volunteers a confirmation when their shift signup is cancelled"
>
<Switch />
</Form.Item>
<Form.Item
label="24h Pre-Shift Reminder"
name="notifyVolunteerShiftReminder"
valuePropName="checked"
extra="Send volunteers a reminder email 24 hours before their shift"
>
<Switch />
</Form.Item>
</div>
),
},

View File

@ -0,0 +1,160 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useOutletContext } from 'react-router-dom';
import { Button, Space, Badge, Spin, Grid, Result, Alert, Typography } from 'antd';
import { ReloadOutlined, LinkOutlined, LockOutlined, WarningOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import type { AppOutletContext } from '@/components/AppLayout';
import type { ServicesStatus, ServicesConfig } from '@/types/api';
import { buildServiceUrl } from '@/lib/service-url';
export default function VaultwardenPage() {
const { setPageHeader } = useOutletContext<AppOutletContext>();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [online, setOnline] = useState<boolean | null>(null);
const [config, setConfig] = useState<ServicesConfig | null>(null);
const [loading, setLoading] = useState(true);
const fetchStatus = useCallback(async () => {
try {
const [statusRes, configRes] = await Promise.all([
api.get<ServicesStatus>('/services/status'),
api.get<ServicesConfig>('/services/config'),
]);
setOnline(statusRes.data.vaultwarden.online);
setConfig(configRes.data);
} catch {
setOnline(false);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchStatus();
}, [fetchStatus]);
const serviceUrl = config
? buildServiceUrl(config.vaultwardenSubdomain, config.domain, config.vaultwardenPort)
: null;
// Detect if we're serving via HTTP (localhost/embed port) instead of HTTPS (tunnel)
const isLocalAccess = !window.location.hostname.includes('.');
const isHttpAccess = window.location.protocol === 'http:';
const httpsUrl = config ? `https://${config.vaultwardenSubdomain}.${config.domain}` : null;
const handleRefresh = useCallback(() => {
fetchStatus();
}, [fetchStatus]);
const headerActions = useMemo(() => (
<Space>
<Badge
status={online === null ? 'processing' : online ? 'success' : 'error'}
text={online === null ? 'Checking...' : online ? 'Online' : 'Offline'}
/>
<Button
icon={<ReloadOutlined />}
onClick={handleRefresh}
size="small"
>
Refresh
</Button>
{serviceUrl && (
<Button
icon={<LinkOutlined />}
href={serviceUrl}
target="_blank"
size="small"
>
Open in New Tab
</Button>
)}
</Space>
), [online, handleRefresh, serviceUrl]);
useEffect(() => {
setPageHeader({
title: 'Password Manager',
actions: headerActions,
fullBleed: true
});
return () => setPageHeader(null);
}, [setPageHeader, headerActions]);
if (isMobile) {
return (
<Result
status="info"
title="Desktop Required"
subTitle="Vaultwarden requires a desktop browser with a larger screen for optimal experience."
icon={<LockOutlined style={{ fontSize: 48 }} />}
/>
);
}
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 80 }}>
<Spin size="large" />
</div>
);
}
if (!online || !serviceUrl) {
return (
<Result
status="error"
title="Vaultwarden Unavailable"
subTitle="Vaultwarden is not running or could not be reached. Check that the Vaultwarden container is started."
extra={
<Button type="primary" onClick={handleRefresh}>
Retry
</Button>
}
/>
);
}
const showHttpWarning = isLocalAccess || isHttpAccess;
return (
<div style={{ display: 'flex', flexDirection: 'column', height: 'calc(100vh - 64px)' }}>
{showHttpWarning && (
<Alert
type="warning"
icon={<WarningOutlined />}
showIcon
banner
message={
<span>
<strong>HTTPS required for account creation.</strong>
{' '}You are accessing Vaultwarden over HTTP (localhost). Browsing an existing vault works,
but creating accounts or accepting invites requires HTTPS.
{httpsUrl ? (
<>
{' '}Use your tunnel URL instead:{' '}
<Typography.Link href={httpsUrl} target="_blank">{httpsUrl}</Typography.Link>
</>
) : (
<> Configure a Pangolin tunnel resource for the <code>vault</code> subdomain to enable HTTPS access.</>
)}
</span>
}
closable
/>
)}
<iframe
src={serviceUrl}
style={{
width: '100%',
flex: 1,
border: 'none',
display: 'block',
}}
title="Vaultwarden Password Manager"
/>
</div>
);
}

View File

@ -0,0 +1,125 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { Button, Spin, Result, Grid } from 'antd';
import { MessageOutlined } from '@ant-design/icons';
import { api } from '@/lib/api';
import { buildServiceUrl } from '@/lib/service-url';
interface RCConfig {
enabled: boolean;
embedPort: number;
subdomain: string;
domain: string;
}
interface RCAuthResponse {
authToken: string;
rcUserId: string;
}
export default function VolunteerChatPage() {
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [online, setOnline] = useState<boolean | null>(null);
const [rcConfig, setRcConfig] = useState<RCConfig | null>(null);
const [authToken, setAuthToken] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const iframeRef = useRef<HTMLIFrameElement>(null);
const fetchAndAuth = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [statusRes, configRes] = await Promise.all([
api.get<{ online: boolean; enabled: boolean }>('/rocketchat/status'),
api.get<RCConfig>('/rocketchat/config'),
]);
setOnline(statusRes.data.online);
setRcConfig(configRes.data);
if (statusRes.data.online && statusRes.data.enabled) {
const authRes = await api.post<RCAuthResponse>('/rocketchat/auth');
setAuthToken(authRes.data.authToken);
}
} catch {
setOnline(false);
setError('Could not connect to chat service');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchAndAuth();
}, [fetchAndAuth]);
const handleIframeLoad = useCallback(() => {
if (authToken && iframeRef.current?.contentWindow) {
iframeRef.current.contentWindow.postMessage(
{ externalCommand: 'login-with-token', token: authToken },
'*',
);
}
}, [authToken]);
if (isMobile) {
return (
<div style={{ padding: 24, textAlign: 'center' }}>
<Result
icon={<MessageOutlined style={{ fontSize: 48 }} />}
title="Desktop Recommended"
subTitle="Chat works best on a larger screen."
/>
</div>
);
}
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 80 }}>
<Spin size="large" />
</div>
);
}
if (!rcConfig?.enabled) {
return (
<div style={{ padding: 24 }}>
<Result status="info" title="Chat Not Available" subTitle="Team chat has not been enabled yet." />
</div>
);
}
if (!online || error) {
return (
<div style={{ padding: 24 }}>
<Result
status="error"
title="Chat Unavailable"
subTitle={error || 'Chat service is not running.'}
extra={<Button type="primary" onClick={fetchAndAuth}>Retry</Button>}
/>
</div>
);
}
const serviceUrl = buildServiceUrl(rcConfig.subdomain, rcConfig.domain, rcConfig.embedPort);
const iframeSrc = `${serviceUrl}/channel/general?layout=embedded`;
return (
<iframe
ref={iframeRef}
src={iframeSrc}
onLoad={handleIframeLoad}
style={{
width: '100%',
height: 'calc(100vh - 64px)',
border: 'none',
display: 'block',
}}
title="Team Chat"
allow="microphone; camera"
/>
);
}

View File

@ -1005,6 +1005,9 @@ export interface ServicesStatus {
miniqr: { online: boolean; url: string };
excalidraw: { online: boolean; url: string };
homepage: { online: boolean; url: string };
vaultwarden: { online: boolean; url: string };
rocketchat: { online: boolean; url: string };
gancio: { online: boolean; url: string };
}
export interface ServicesConfig {
@ -1039,6 +1042,15 @@ export interface ServicesConfig {
// Homepage (service dashboard)
homepagePort: number;
homepageSubdomain: string;
// Vaultwarden (password manager)
vaultwardenPort: number;
vaultwardenSubdomain: string;
// Rocket.Chat (team chat)
rocketchatPort: number;
rocketchatSubdomain: string;
// Gancio (event management)
gancioPort: number;
gancioSubdomain: string;
}
// --- Site Settings ---
@ -1088,6 +1100,16 @@ export interface SiteSettings {
enableMediaFeatures: boolean;
enablePayments: boolean;
enableGalleryAds: boolean;
enableChat: boolean;
enableEvents: boolean;
// Notification settings
notifyAdminShiftSignup: boolean;
notifyAdminResponseSubmitted: boolean;
notifyAdminSignRequested: boolean;
notifyAdminShiftCancellation: boolean;
notifyVolunteerSessionSummary: boolean;
notifyVolunteerCancellation: boolean;
notifyVolunteerShiftReminder: boolean;
createdAt: string;
updatedAt: string;
}
@ -1693,6 +1715,96 @@ export interface ApiMetrics {
statusBreakdown: { status: string; count: number }[];
}
// --- Canvass Export ---
export interface ExportContactsPreviewResult {
totalContacts: number;
contactsWithEmail: number;
byCut: { cutId: string; cutName: string; contacts: number; withEmail: number }[];
byOutcome: Record<string, number>;
bySupportLevel: Record<string, number>;
}
export interface ExportContactsResult {
created: number;
skippedDuplicate: number;
skippedNoEmail: number;
campaignId: string;
campaignTitle: string;
}
export interface CutCampaignAnalytics {
cutId: string;
cutName: string;
totalAddresses: number;
visitedAddresses: number;
completionPct: number;
addressesWithEmail: number;
supportBreakdown: Record<string, number>;
}
// --- Dashboard Activity Feed ---
export interface ActivityItem {
id: string;
type: 'shift_signup' | 'response_submitted' | 'canvass_completed' | 'email_sent' | 'user_created';
module: 'map' | 'influence' | 'users';
title: string;
description: string;
timestamp: string;
}
export interface ActivityFeedResult {
items: ActivityItem[];
total: number;
page: number;
limit: number;
}
// --- Dashboard Connectivity ---
export interface ConnectivityStatus {
smtp: boolean;
listmonk: boolean;
rocketchat: boolean;
gancio: boolean;
}
// --- Dashboard Today Events (Gancio) ---
export interface TodayEvent {
id: number;
title: string;
description: string;
placeName: string;
startTime: string;
endTime: string | null;
tags: string[];
}
export interface TodayEventsResult {
enabled: boolean;
events: TodayEvent[];
total: number;
}
// --- Dashboard Chat Summary (Rocket.Chat) ---
export interface ChatMessage {
id: string;
channel: string;
username: string;
text: string;
timestamp: string;
isBot: boolean;
}
export interface ChatSummaryResult {
enabled: boolean;
messages: ChatMessage[];
unreadChannels: number;
}
// --- Dashboard Time-Series ---
export interface TimeSeriesPoint {

22
api/prisma/init-gancio-db.sh Executable file
View File

@ -0,0 +1,22 @@
#!/bin/bash
###############################################################################
# Gancio Database Initialization Script
###############################################################################
# Creates a separate PostgreSQL database for Gancio event management.
#
# Database: gancio
# Purpose: Stores Gancio events, users, and configuration
# Runs: Automatically on first PostgreSQL container startup via docker-entrypoint-initdb.d
###############################################################################
set -e
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
-- Create Gancio database if it doesn't exist
SELECT 'CREATE DATABASE gancio'
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'gancio')\gexec
-- Grant all privileges to the main user
GRANT ALL PRIVILEGES ON DATABASE gancio TO ${POSTGRES_USER};
EOSQL
echo "Gancio database 'gancio' created successfully"

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "site_settings" ADD COLUMN IF NOT EXISTS "notifyAdminShiftCancellation" BOOLEAN NOT NULL DEFAULT true;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "shifts" ADD COLUMN "gancioEventId" INTEGER;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "site_settings" ADD COLUMN IF NOT EXISTS "enable_events" BOOLEAN NOT NULL DEFAULT false;

View File

@ -647,6 +647,9 @@ model Shift {
series ShiftSeries? @relation(fields: [seriesId], references: [id], onDelete: SetNull)
isException Boolean @default(false)
// Gancio event sync
gancioEventId Int?
createdBy String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@ -844,6 +847,17 @@ model SiteSettings {
enableMediaFeatures Boolean @default(true) @map("enable_media_features")
enablePayments Boolean @default(false)
enableGalleryAds Boolean @default(false) @map("enable_gallery_ads")
enableChat Boolean @default(false) @map("enable_chat")
enableEvents Boolean @default(false) @map("enable_events")
// Notification settings
notifyAdminShiftSignup Boolean @default(true)
notifyAdminResponseSubmitted Boolean @default(true)
notifyAdminSignRequested Boolean @default(true)
notifyAdminShiftCancellation Boolean @default(true)
notifyVolunteerSessionSummary Boolean @default(true)
notifyVolunteerCancellation Boolean @default(true)
notifyVolunteerShiftReminder Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

View File

@ -311,6 +311,25 @@ async function main() {
buttonText: 'Buy Now',
},
},
{
id: 'default-gancio-events',
type: 'gancio-events',
label: 'Event Calendar',
category: 'Content',
sortOrder: 12,
schema: {
maxlength: { type: 'number', label: 'Max Events', default: 10 },
theme: { type: 'select', label: 'Theme', options: ['dark', 'light'], default: 'dark' },
tags: { type: 'string', label: 'Filter by Tags (comma-separated)' },
title: { type: 'string', label: 'Section Title', default: 'Upcoming Events' },
},
defaults: {
maxlength: 10,
theme: 'dark',
tags: '',
title: 'Upcoming Events',
},
},
];
for (const block of defaultBlocks) {
@ -531,6 +550,89 @@ async function seedEmailTemplates(admin: { id: string; email: string }) {
{ key: 'ORGANIZATION_NAME', label: 'Organization Name', description: 'Name of the organization', isRequired: true, isConditional: false, sampleValue: 'Changemaker Lite', sortOrder: 7 },
],
},
{
key: 'admin-shift-signup-alert',
name: 'Admin: New Shift Signup Alert',
description: 'Notification sent to admins when a volunteer signs up for a shift',
category: EmailTemplateCategory.MAP,
subjectLine: 'New shift signup — {{SHIFT_TITLE}}',
isSystem: true,
variables: [
{ key: 'ORGANIZATION_NAME', label: 'Organization Name', description: 'Name of the organization', isRequired: true, isConditional: false, sampleValue: 'Changemaker Lite', sortOrder: 0 },
{ key: 'SHIFT_TITLE', label: 'Shift Title', description: 'Title of the shift', isRequired: true, isConditional: false, sampleValue: 'Weekend Canvassing - Downtown', sortOrder: 1 },
{ key: 'SHIFT_DATE', label: 'Shift Date', description: 'Date of the shift', isRequired: true, isConditional: false, sampleValue: 'Saturday, February 22, 2026', sortOrder: 2 },
{ key: 'VOLUNTEER_NAME', label: 'Volunteer Name', description: 'Name of the volunteer who signed up', isRequired: true, isConditional: false, sampleValue: 'Jane Doe', sortOrder: 3 },
{ key: 'VOLUNTEER_EMAIL', label: 'Volunteer Email', description: 'Email of the volunteer', isRequired: true, isConditional: false, sampleValue: 'jane@example.com', sortOrder: 4 },
{ key: 'SIGNUP_SOURCE', label: 'Signup Source', description: 'How the volunteer signed up (Public Form, Authenticated Volunteer)', isRequired: true, isConditional: false, sampleValue: 'Public Form', sortOrder: 5 },
{ key: 'ADMIN_URL', label: 'Admin URL', description: 'URL to the admin shifts page', isRequired: true, isConditional: false, sampleValue: 'https://app.cmlite.org/app/map/shifts', sortOrder: 6 },
],
},
{
key: 'admin-response-submitted-alert',
name: 'Admin: Response Submitted Alert',
description: 'Notification sent to admins when a new response is submitted to the response wall',
category: EmailTemplateCategory.INFLUENCE,
subjectLine: 'New response submitted — {{CAMPAIGN_TITLE}}',
isSystem: true,
variables: [
{ key: 'ORGANIZATION_NAME', label: 'Organization Name', description: 'Name of the organization', isRequired: true, isConditional: false, sampleValue: 'Changemaker Lite', sortOrder: 0 },
{ key: 'CAMPAIGN_TITLE', label: 'Campaign Title', description: 'Title of the campaign', isRequired: true, isConditional: false, sampleValue: 'Support Climate Action Bill C-12', sortOrder: 1 },
{ key: 'REPRESENTATIVE_NAME', label: 'Representative Name', description: 'Name of the representative the response is about', isRequired: true, isConditional: false, sampleValue: 'Hon. John Smith', sortOrder: 2 },
{ key: 'RESPONSE_TYPE', label: 'Response Type', description: 'Type of response (Support, Oppose, etc.)', isRequired: true, isConditional: false, sampleValue: 'SUPPORT', sortOrder: 3 },
{ key: 'SUBMITTER_NAME', label: 'Submitter Name', description: 'Name of the person who submitted', isRequired: true, isConditional: false, sampleValue: 'Jane Doe', sortOrder: 4 },
{ key: 'ADMIN_URL', label: 'Admin URL', description: 'URL to the admin responses page', isRequired: true, isConditional: false, sampleValue: 'https://app.cmlite.org/app/influence/responses', sortOrder: 5 },
],
},
{
key: 'admin-sign-requested-alert',
name: 'Admin: Sign Requested Alert',
description: 'Notification sent to admins when a resident requests a yard sign during canvassing',
category: EmailTemplateCategory.MAP,
subjectLine: 'Sign requested — {{ADDRESS}}',
isSystem: true,
variables: [
{ key: 'ORGANIZATION_NAME', label: 'Organization Name', description: 'Name of the organization', isRequired: true, isConditional: false, sampleValue: 'Changemaker Lite', sortOrder: 0 },
{ key: 'VOLUNTEER_NAME', label: 'Canvasser Name', description: 'Name of the volunteer who recorded the sign request', isRequired: true, isConditional: false, sampleValue: 'John Smith', sortOrder: 1 },
{ key: 'ADDRESS', label: 'Address', description: 'Street address where sign was requested', isRequired: true, isConditional: false, sampleValue: '123 Main Street', sortOrder: 2 },
{ key: 'SHIFT_TITLE', label: 'Shift Title', description: 'Title of the canvassing shift', isRequired: true, isConditional: false, sampleValue: 'Weekend Canvassing - Downtown', sortOrder: 3 },
{ key: 'SIGN_SIZE', label: 'Sign Size', description: 'Requested sign size', isRequired: false, isConditional: true, sampleValue: 'Large', sortOrder: 4 },
{ key: 'ADMIN_URL', label: 'Admin URL', description: 'URL to the admin canvass dashboard', isRequired: true, isConditional: false, sampleValue: 'https://app.cmlite.org/app/canvass/dashboard', sortOrder: 5 },
],
},
{
key: 'volunteer-session-summary',
name: 'Volunteer: Canvass Session Summary',
description: 'Summary email sent to a volunteer after completing a canvassing session',
category: EmailTemplateCategory.MAP,
subjectLine: 'Canvass session summary — {{CUT_NAME}}',
isSystem: true,
variables: [
{ key: 'ORGANIZATION_NAME', label: 'Organization Name', description: 'Name of the organization', isRequired: true, isConditional: false, sampleValue: 'Changemaker Lite', sortOrder: 0 },
{ key: 'VOLUNTEER_NAME', label: 'Volunteer Name', description: 'Name of the volunteer', isRequired: true, isConditional: false, sampleValue: 'Jane Doe', sortOrder: 1 },
{ key: 'CUT_NAME', label: 'Cut/Area Name', description: 'Name of the canvassing area', isRequired: true, isConditional: false, sampleValue: 'Downtown Core', sortOrder: 2 },
{ key: 'SESSION_DATE', label: 'Session Date', description: 'Date of the session', isRequired: true, isConditional: false, sampleValue: 'Saturday, February 22, 2026', sortOrder: 3 },
{ key: 'VISIT_COUNT', label: 'Visit Count', description: 'Number of doors visited', isRequired: true, isConditional: false, sampleValue: '42', sortOrder: 4 },
{ key: 'DURATION_MINUTES', label: 'Duration (minutes)', description: 'Session duration in minutes', isRequired: true, isConditional: false, sampleValue: '95', sortOrder: 5 },
{ key: 'DISTANCE_KM', label: 'Distance (km)', description: 'Distance walked in kilometers', isRequired: true, isConditional: false, sampleValue: '2.3', sortOrder: 6 },
{ key: 'OUTCOME_BREAKDOWN', label: 'Outcome Breakdown', description: 'HTML table (email) or text list (plain text) of visit outcomes', isRequired: false, isConditional: true, sampleValue: 'Spoke With: 20, Not Home: 15, Refused: 7', sortOrder: 7 },
],
},
{
key: 'volunteer-cancellation-ack',
name: 'Volunteer: Signup Cancellation Acknowledgement',
description: 'Confirmation email sent to a volunteer when their shift signup is cancelled',
category: EmailTemplateCategory.MAP,
subjectLine: 'Signup cancelled — {{SHIFT_TITLE}}',
isSystem: true,
variables: [
{ key: 'ORGANIZATION_NAME', label: 'Organization Name', description: 'Name of the organization', isRequired: true, isConditional: false, sampleValue: 'Changemaker Lite', sortOrder: 0 },
{ key: 'VOLUNTEER_NAME', label: 'Volunteer Name', description: 'Name of the volunteer', isRequired: true, isConditional: false, sampleValue: 'Jane Doe', sortOrder: 1 },
{ key: 'SHIFT_TITLE', label: 'Shift Title', description: 'Title of the cancelled shift signup', isRequired: true, isConditional: false, sampleValue: 'Weekend Canvassing - Downtown', sortOrder: 2 },
{ key: 'SHIFT_DATE', label: 'Shift Date', description: 'Date of the shift', isRequired: true, isConditional: false, sampleValue: 'Saturday, February 22, 2026', sortOrder: 3 },
{ key: 'SHIFT_TIME', label: 'Shift Time', description: 'Time range of the shift', isRequired: true, isConditional: false, sampleValue: '10:00 AM — 2:00 PM', sortOrder: 4 },
{ key: 'SIGNUP_URL', label: 'Signup URL', description: 'URL to browse available shifts', isRequired: true, isConditional: false, sampleValue: 'https://app.cmlite.org/shifts', sortOrder: 5 },
],
},
];
let seededCount = 0;

View File

@ -11,6 +11,11 @@ const envSchema = z.object({
ADMIN_URL: z.string().default('http://localhost:3000'),
DOMAIN: z.string().default('cmlite.org'),
// Bunker Ops (Fleet Management)
INSTANCE_LABEL: z.string().default(''),
BUNKER_OPS_ENABLED: z.string().default('false'),
BUNKER_OPS_REMOTE_WRITE_URL: z.string().default(''),
// Database
DATABASE_URL: z.string(),
@ -45,6 +50,7 @@ const envSchema = z.object({
LISTMONK_ADMIN_USER: z.string().default('admin'),
LISTMONK_ADMIN_PASSWORD: z.string().default(''),
LISTMONK_SYNC_ENABLED: z.string().default('false'),
LISTMONK_WEBHOOK_SECRET: z.string().default(''),
LISTMONK_PROXY_PORT: z.coerce.number().default(9002),
// Represent API (Canadian electoral data)
@ -101,6 +107,25 @@ const envSchema = z.object({
HOMEPAGE_URL: z.string().default('http://homepage-changemaker:3000'),
HOMEPAGE_EMBED_PORT: z.coerce.number().default(8887),
// Vaultwarden (password manager)
VAULTWARDEN_URL: z.string().default('http://vaultwarden-changemaker:80'),
VAULTWARDEN_EMBED_PORT: z.coerce.number().default(8890),
// Rocket.Chat (team chat)
ROCKETCHAT_URL: z.string().default('http://rocketchat-changemaker:3000'),
ROCKETCHAT_ADMIN_USER: z.string().default(''),
ROCKETCHAT_ADMIN_PASSWORD: z.string().default(''),
ROCKETCHAT_EMBED_PORT: z.coerce.number().default(8891),
ENABLE_CHAT: z.string().default('false'),
// Gancio (event management)
GANCIO_URL: z.string().default('http://gancio-changemaker:13120'),
GANCIO_PORT: z.coerce.number().default(8092),
GANCIO_EMBED_PORT: z.coerce.number().default(8892),
GANCIO_ADMIN_USER: z.string().default('admin'),
GANCIO_ADMIN_PASSWORD: z.string().default(''),
GANCIO_SYNC_ENABLED: z.string().default('false'),
// Pangolin (tunnel / reverse proxy)
PANGOLIN_API_URL: z.string()
.default('')

View File

@ -9,6 +9,10 @@ import {
getApiMetrics,
getTimeSeries,
getContainerResources,
getActivityFeed,
getConnectivity,
getTodayEvents,
getChatSummary,
} from './dashboard.service';
const router = Router();
@ -121,4 +125,47 @@ router.get('/container-resources', requireRole('SUPER_ADMIN'), async (_req: Requ
}
});
// GET /api/dashboard/activity — recent activity feed (paginated)
router.get('/activity', async (req: Request, res: Response, next: NextFunction) => {
try {
const page = Math.max(1, parseInt(req.query.page as string) || 1);
const limit = Math.min(50, Math.max(1, parseInt(req.query.limit as string) || 20));
const module = (req.query.module as string) || 'all';
const result = await getActivityFeed({ page, limit, module });
res.json(result);
} catch (err) {
next(err);
}
});
// GET /api/dashboard/connectivity — service connectivity checks
router.get('/connectivity', async (_req: Request, res: Response, next: NextFunction) => {
try {
const connectivity = await getConnectivity();
res.json(connectivity);
} catch (err) {
next(err);
}
});
// GET /api/dashboard/today-events — today's events from Gancio
router.get('/today-events', async (_req: Request, res: Response, next: NextFunction) => {
try {
const events = await getTodayEvents();
res.json(events);
} catch (err) {
next(err);
}
});
// GET /api/dashboard/chat-summary — recent messages from Rocket.Chat
router.get('/chat-summary', async (_req: Request, res: Response, next: NextFunction) => {
try {
const summary = await getChatSummary();
res.json(summary);
} catch (err) {
next(err);
}
});
export const dashboardRouter = router;

View File

@ -7,6 +7,9 @@ import { env } from '../../config/env';
import { fetchWithTimeout } from '../../utils/fetch-with-timeout';
import { validatePromQLQueries } from '../../utils/promql-validator';
import { isServiceOnline } from '../../utils/health-check';
import { listmonkClient } from '../../services/listmonk.client';
import { gancioClient } from '../../services/gancio.client';
import { rocketchatClient } from '../../services/rocketchat.client';
import { logger } from '../../utils/logger';
// --- Types ---
@ -264,6 +267,171 @@ export async function getDashboardSummary(): Promise<DashboardSummary> {
};
}
// --- Activity Feed ---
export interface ActivityItem {
id: string;
type: 'shift_signup' | 'response_submitted' | 'canvass_completed' | 'email_sent' | 'user_created';
module: 'map' | 'influence' | 'users';
title: string;
description: string;
timestamp: string;
}
export interface ActivityFeedResult {
items: ActivityItem[];
total: number;
page: number;
limit: number;
}
export async function getActivityFeed(opts: {
page: number;
limit: number;
module?: string;
}): Promise<ActivityFeedResult> {
const { page, limit, module } = opts;
const skip = (page - 1) * limit;
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); // last 7 days
const items: ActivityItem[] = [];
const queries: Promise<void>[] = [];
// Shift signups (map module)
if (!module || module === 'all' || module === 'map') {
queries.push(
prisma.shiftSignup.findMany({
where: { signupDate: { gte: since }, status: 'CONFIRMED' },
orderBy: { signupDate: 'desc' },
take: limit * 2,
select: { id: true, userName: true, userEmail: true, shiftTitle: true, signupDate: true },
}).then(signups => {
for (const s of signups) {
items.push({
id: `signup-${s.id}`,
type: 'shift_signup',
module: 'map',
title: 'Shift Signup',
description: `${s.userName || s.userEmail} signed up for ${s.shiftTitle}`,
timestamp: s.signupDate.toISOString(),
});
}
}).catch(() => {}),
);
}
// Canvass sessions (map module)
if (!module || module === 'all' || module === 'map') {
queries.push(
prisma.canvassSession.findMany({
where: { status: 'COMPLETED', endedAt: { gte: since } },
orderBy: { endedAt: 'desc' },
take: limit * 2,
select: { id: true, endedAt: true, userId: true, cut: { select: { name: true } } },
}).then(async sessions => {
const userIds = sessions.map(s => s.userId);
const users = await prisma.user.findMany({
where: { id: { in: userIds } },
select: { id: true, name: true, email: true },
});
const userMap = new Map(users.map(u => [u.id, u]));
for (const s of sessions) {
const user = userMap.get(s.userId);
items.push({
id: `canvass-${s.id}`,
type: 'canvass_completed',
module: 'map',
title: 'Canvass Completed',
description: `${user?.name || user?.email || 'Unknown'} completed a session${s.cut?.name ? ` in ${s.cut.name}` : ''}`,
timestamp: (s.endedAt || new Date()).toISOString(),
});
}
}).catch(() => {}),
);
}
// Responses (influence module)
if (!module || module === 'all' || module === 'influence') {
queries.push(
prisma.representativeResponse.findMany({
where: { createdAt: { gte: since } },
orderBy: { createdAt: 'desc' },
take: limit * 2,
select: { id: true, createdAt: true, submittedByName: true, campaign: { select: { title: true } } },
}).then(responses => {
for (const r of responses) {
items.push({
id: `response-${r.id}`,
type: 'response_submitted',
module: 'influence',
title: 'Response Submitted',
description: `${r.submittedByName || 'Anonymous'} submitted a response for ${r.campaign.title}`,
timestamp: r.createdAt.toISOString(),
});
}
}).catch(() => {}),
);
}
// Users (users module)
if (!module || module === 'all' || module === 'users') {
queries.push(
prisma.user.findMany({
where: { createdAt: { gte: since } },
orderBy: { createdAt: 'desc' },
take: limit * 2,
select: { id: true, name: true, email: true, role: true, createdAt: true },
}).then(users => {
for (const u of users) {
items.push({
id: `user-${u.id}`,
type: 'user_created',
module: 'users',
title: 'New User',
description: `${u.name || u.email} (${u.role.replace(/_/g, ' ')})`,
timestamp: u.createdAt.toISOString(),
});
}
}).catch(() => {}),
);
}
await Promise.all(queries);
// Sort by timestamp descending, paginate
items.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
const total = items.length;
const paged = items.slice(skip, skip + limit);
return { items: paged, total, page, limit };
}
// --- Connectivity Checks ---
export interface ConnectivityStatus {
smtp: boolean;
listmonk: boolean;
rocketchat: boolean;
gancio: boolean;
}
export async function getConnectivity(): Promise<ConnectivityStatus> {
const [smtp, listmonk, rocketchat, gancio] = await Promise.all([
isServiceOnline(`${env.SMTP_HOST}`, 3000).catch(() => false),
listmonkClient.checkHealth().catch(() => false),
isServiceOnline(env.ROCKETCHAT_URL || '', 3000).catch(() => false),
gancioClient.isAvailable().catch(() => false),
]);
return {
smtp: !!smtp,
listmonk: !!listmonk,
rocketchat: !!rocketchat,
gancio: !!gancio,
};
}
export function getSystemInfo(): SystemInfo {
const totalMem = os.totalmem();
const freeMem = os.freemem();
@ -619,3 +787,140 @@ export async function getApiMetrics(): Promise<ApiMetrics | null> {
return null;
}
}
// --- Today's Events from Gancio ---
export interface TodayEvent {
id: number;
title: string;
description: string;
placeName: string;
startTime: string; // ISO string
endTime: string | null;
tags: string[];
}
export interface TodayEventsResult {
enabled: boolean;
events: TodayEvent[];
total: number;
}
export async function getTodayEvents(): Promise<TodayEventsResult> {
if (!gancioClient.enabled) {
return { enabled: false, events: [], total: 0 };
}
try {
// Gancio public API: GET /api/events returns all upcoming events
const url = `${env.GANCIO_URL}/api/events`;
const res = await fetch(url, { signal: AbortSignal.timeout(5000) });
if (!res.ok) {
logger.debug(`Gancio events API returned ${res.status}`);
return { enabled: true, events: [], total: 0 };
}
const rawEvents = await res.json() as Array<{
id: number;
title: string;
description: string;
place_name: string;
place_address: string;
start_datetime: number;
end_datetime?: number;
tags: string[];
}>;
// Filter to today's events (UTC-based day boundaries matching local server time)
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const todayEnd = new Date(todayStart.getTime() + 86400000);
const todayStartUnix = Math.floor(todayStart.getTime() / 1000);
const todayEndUnix = Math.floor(todayEnd.getTime() / 1000);
const todayEvents = rawEvents
.filter(e => e.start_datetime >= todayStartUnix && e.start_datetime < todayEndUnix)
.sort((a, b) => a.start_datetime - b.start_datetime)
.slice(0, 20)
.map(e => ({
id: e.id,
title: e.title,
description: (e.description || '').slice(0, 200),
placeName: e.place_name || '',
startTime: new Date(e.start_datetime * 1000).toISOString(),
endTime: e.end_datetime ? new Date(e.end_datetime * 1000).toISOString() : null,
tags: Array.isArray(e.tags) ? e.tags : [],
}));
return { enabled: true, events: todayEvents, total: todayEvents.length };
} catch (err) {
logger.debug('Failed to fetch today events from Gancio:', err);
return { enabled: true, events: [], total: 0 };
}
}
// --- Chat Summary from Rocket.Chat ---
export interface ChatMessage {
id: string;
channel: string;
username: string;
text: string;
timestamp: string;
isBot: boolean;
}
export interface ChatSummaryResult {
enabled: boolean;
messages: ChatMessage[];
unreadChannels: number;
}
export async function getChatSummary(): Promise<ChatSummaryResult> {
const chatEnabled = env.ENABLE_CHAT === 'true';
if (!chatEnabled) {
return { enabled: false, messages: [], unreadChannels: 0 };
}
try {
const online = await rocketchatClient.healthCheck();
if (!online) {
return { enabled: true, messages: [], unreadChannels: 0 };
}
// Fetch recent messages from our notification channels
const channels = ['general', 'shifts', 'canvassing', 'campaigns'];
const allMessages: ChatMessage[] = [];
await Promise.all(
channels.map(async (channelName) => {
const messages = await rocketchatClient.getChannelHistory(channelName, 5);
for (const msg of messages) {
if (!msg.msg?.trim()) continue;
allMessages.push({
id: msg._id,
channel: channelName,
username: msg.alias || msg.u?.username || 'unknown',
text: msg.msg.slice(0, 300),
timestamp: msg.ts,
isBot: !!(msg.bot || msg.alias),
});
}
}),
);
// Sort by timestamp descending, take most recent 15
allMessages.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
const messages = allMessages.slice(0, 15);
return {
enabled: true,
messages,
unreadChannels: 0,
};
} catch (err) {
logger.debug('Failed to fetch chat summary:', err);
return { enabled: true, messages: [], unreadChannels: 0 };
}
}

View File

@ -1,10 +1,14 @@
import { randomBytes } from 'crypto';
import { CampaignStatus, Prisma, ResponseStatus } from '@prisma/client';
import { CampaignStatus, Prisma, ResponseStatus, UserRole } from '@prisma/client';
import { prisma } from '../../../config/database';
import { AppError } from '../../../middleware/error-handler';
import { emailService } from '../../../services/email.service';
import { notificationQueueService } from '../../../services/notification-queue.service';
import { getAdminEmailsByRole, isNotificationEnabled } from '../../../services/notification.helper';
import { env } from '../../../config/env';
import { logger } from '../../../utils/logger';
import { recordResponseSubmission } from '../../../utils/metrics';
import { rocketchatWebhookService } from '../../../services/rocketchat-webhook.service';
import type {
SubmitResponseInput,
ListPublicResponsesInput,
@ -77,6 +81,33 @@ export const responsesService = {
recordResponseSubmission();
// Notification: admin response submitted alert
try {
if (await isNotificationEnabled('notifyAdminResponseSubmitted')) {
const adminEmails = await getAdminEmailsByRole([UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN]);
if (adminEmails.length > 0) {
const adminUrl = `${env.ADMIN_URL || 'http://localhost:3000'}/app/influence/responses`;
await notificationQueueService.enqueue({
type: 'admin-response-submitted',
adminEmails,
campaignTitle: campaign.title,
representativeName: data.representativeName,
responseType: data.responseType,
submitterName: data.isAnonymous ? 'Anonymous' : (data.submittedByName || 'Anonymous'),
adminUrl,
});
}
}
} catch (err) {
logger.error('Failed to enqueue response submitted notification:', err);
}
// Notify Rocket.Chat
rocketchatWebhookService.onCampaignResponseSubmitted({
campaignTitle: campaign.title,
representativeName: data.representativeName,
}).catch(() => {});
return {
id: response.id,
status: response.status,

View File

@ -0,0 +1,69 @@
import { Router, Request, Response, NextFunction } from 'express';
import { prisma } from '../../config/database';
import { env } from '../../config/env';
import { logger } from '../../utils/logger';
const router = Router();
/**
* POST /api/listmonk/webhook?secret=...
*
* Handles Listmonk webhook events for reverse sync (e.g., unsubscribes).
* Validates a shared secret query parameter. No JWT auth Listmonk calls this.
*/
router.post(
'/webhook',
async (req: Request, res: Response, next: NextFunction) => {
try {
const secret = req.query.secret as string;
if (!env.LISTMONK_WEBHOOK_SECRET || secret !== env.LISTMONK_WEBHOOK_SECRET) {
res.status(403).json({ error: 'Invalid webhook secret' });
return;
}
const event = req.body;
const eventType = event?.event;
if (eventType === 'subscriber.unsubscribed' || eventType === 'subscriber.disabled') {
const email = event?.data?.subscriber?.email;
if (!email) {
res.json({ ok: true, action: 'skipped', reason: 'no email' });
return;
}
// Store opt-out flag in user's permissions JSON field
const user = await prisma.user.findUnique({
where: { email },
select: { id: true, permissions: true },
});
if (user) {
const permissions = (user.permissions as Record<string, unknown>) || {};
permissions.listmonkOptOut = true;
permissions.listmonkOptOutAt = new Date().toISOString();
await prisma.user.update({
where: { id: user.id },
data: { permissions: permissions as any },
});
logger.info(`Listmonk webhook: marked user ${email} as opted-out`);
res.json({ ok: true, action: 'opted_out', email });
return;
}
logger.debug(`Listmonk webhook: no user found for ${email}`);
res.json({ ok: true, action: 'skipped', reason: 'user not found' });
return;
}
// Unknown event type — acknowledge but don't process
logger.debug(`Listmonk webhook: unhandled event type "${eventType}"`);
res.json({ ok: true, action: 'ignored', eventType });
} catch (err) {
next(err);
}
},
);
export { router as listmonkWebhookRouter };

View File

@ -4,6 +4,7 @@ import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { listmonkClient } from '../../services/listmonk.client';
import { listmonkSyncService } from '../../services/listmonk-sync.service';
import { listmonkEventSyncService } from '../../services/listmonk-event-sync.service';
import { env } from '../../config/env';
const router = Router();
@ -141,6 +142,14 @@ router.post(
},
);
// GET /api/listmonk/event-sync-stats — event-driven sync stats
router.get(
'/event-sync-stats',
(_req: Request, res: Response) => {
res.json(listmonkEventSyncService.getStats());
},
);
// GET /api/listmonk/proxy-url — get proxy port + token for iframe embedding
router.get(
'/proxy-url',

View File

@ -0,0 +1,57 @@
import { Router, Request, Response, NextFunction } from 'express';
import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware';
import { validate } from '../../../middleware/validate';
import { exportContactsPreviewSchema, exportContactsSchema } from './canvass-export.schemas';
import {
previewExportContacts,
exportContactsToCampaign,
getCutCampaignAnalytics,
} from './canvass-export.service';
const router = Router();
router.use(authenticate);
router.use(requireRole('SUPER_ADMIN', 'MAP_ADMIN', 'INFLUENCE_ADMIN'));
// POST /api/map/canvass/export-contacts/preview — preview matching contacts
router.post(
'/export-contacts/preview',
validate(exportContactsPreviewSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await previewExportContacts(req.body);
res.json(result);
} catch (err) {
next(err);
}
},
);
// POST /api/map/canvass/export-contacts — export contacts to campaign
router.post(
'/export-contacts',
validate(exportContactsSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await exportContactsToCampaign(req.body);
res.json(result);
} catch (err) {
next(err);
}
},
);
// GET /api/map/canvass/analytics/cuts — per-cut campaign analytics
router.get(
'/analytics/cuts',
async (_req: Request, res: Response, next: NextFunction) => {
try {
const analytics = await getCutCampaignAnalytics();
res.json({ cuts: analytics });
} catch (err) {
next(err);
}
},
);
export { router as canvassExportRouter };

View File

@ -0,0 +1,21 @@
import { z } from 'zod';
export const exportContactsPreviewSchema = z.object({
cutIds: z.array(z.string()).min(1, 'At least one cut required'),
outcomes: z.array(z.enum([
'NOT_HOME', 'REFUSED', 'MOVED', 'ALREADY_VOTED',
'SPOKE_WITH', 'LEFT_LITERATURE', 'COME_BACK_LATER',
])).optional(),
supportLevelMin: z.number().int().min(1).max(4).optional(),
supportLevelMax: z.number().int().min(1).max(4).optional(),
hasEmail: z.boolean().optional(),
hasSign: z.boolean().optional(),
visitedSince: z.string().datetime().optional(),
});
export const exportContactsSchema = exportContactsPreviewSchema.extend({
campaignId: z.string().min(1, 'Campaign ID required'),
});
export type ExportContactsPreviewInput = z.infer<typeof exportContactsPreviewSchema>;
export type ExportContactsInput = z.infer<typeof exportContactsSchema>;

View File

@ -0,0 +1,392 @@
import { Prisma, SupportLevel, VisitOutcome } from '@prisma/client';
import { prisma } from '../../../config/database';
import { isPointInPolygon, parseGeoJsonPolygon, calculateBounds } from '../../../utils/spatial';
import { logger } from '../../../utils/logger';
import type { ExportContactsPreviewInput, ExportContactsInput } from './canvass-export.schemas';
const SUPPORT_LEVEL_ORDER: Record<string, number> = {
LEVEL_1: 1,
LEVEL_2: 2,
LEVEL_3: 3,
LEVEL_4: 4,
};
const MAX_EXPORT = 10_000;
// --- Types ---
interface ContactCandidate {
addressId: string;
firstName: string | null;
lastName: string | null;
email: string | null;
supportLevel: SupportLevel | null;
sign: boolean;
cutName: string;
locationAddress: string;
unitNumber: string | null;
latestOutcome: VisitOutcome | null;
latestVisitDate: Date | null;
}
export interface ExportPreviewResult {
totalContacts: number;
contactsWithEmail: number;
byCut: { cutId: string; cutName: string; contacts: number; withEmail: number }[];
byOutcome: Record<string, number>;
bySupportLevel: Record<string, number>;
}
export interface ExportResult {
created: number;
skippedDuplicate: number;
skippedNoEmail: number;
campaignId: string;
campaignTitle: string;
}
export interface CutCampaignAnalytics {
cutId: string;
cutName: string;
totalAddresses: number;
visitedAddresses: number;
completionPct: number;
addressesWithEmail: number;
supportBreakdown: Record<string, number>;
}
// --- Helpers ---
async function resolveContactsFromCuts(
filters: ExportContactsPreviewInput,
): Promise<ContactCandidate[]> {
const cuts = await prisma.cut.findMany({
where: { id: { in: filters.cutIds } },
select: { id: true, name: true, geojson: true },
});
const allContacts: ContactCandidate[] = [];
for (const cut of cuts) {
const polygons = parseGeoJsonPolygon(cut.geojson);
// Calculate bounds for a fast DB pre-filter
const allCoords = polygons.flat();
const bounds = calculateBounds(allCoords);
const locations = await prisma.location.findMany({
where: {
latitude: { gte: new Prisma.Decimal(bounds.minLat.toString()), lte: new Prisma.Decimal(bounds.maxLat.toString()) },
longitude: { gte: new Prisma.Decimal(bounds.minLng.toString()), lte: new Prisma.Decimal(bounds.maxLng.toString()) },
},
select: {
id: true,
latitude: true,
longitude: true,
address: true,
addresses: {
select: {
id: true,
unitNumber: true,
firstName: true,
lastName: true,
email: true,
supportLevel: true,
sign: true,
},
},
},
});
// In-memory polygon filter
for (const loc of locations) {
const lat = Number(loc.latitude);
const lng = Number(loc.longitude);
if (!polygons.some(p => isPointInPolygon(lat, lng, p))) continue;
for (const addr of loc.addresses) {
allContacts.push({
addressId: addr.id,
firstName: addr.firstName,
lastName: addr.lastName,
email: addr.email,
supportLevel: addr.supportLevel,
sign: addr.sign,
cutName: cut.name,
locationAddress: loc.address,
unitNumber: addr.unitNumber,
latestOutcome: null,
latestVisitDate: null,
});
}
}
}
if (allContacts.length === 0) return [];
// Fetch latest visit for each address to apply outcome/date filters
const addressIds = [...new Set(allContacts.map(c => c.addressId))];
const latestVisits = await prisma.canvassVisit.findMany({
where: { addressId: { in: addressIds } },
distinct: ['addressId'],
orderBy: { visitedAt: 'desc' },
select: { addressId: true, outcome: true, visitedAt: true },
});
const visitMap = new Map(latestVisits.map(v => [v.addressId, v]));
// Annotate contacts with latest visit data
for (const contact of allContacts) {
const visit = visitMap.get(contact.addressId);
if (visit) {
contact.latestOutcome = visit.outcome;
contact.latestVisitDate = visit.visitedAt;
}
}
// Apply filters
let filtered = allContacts;
if (filters.outcomes && filters.outcomes.length > 0) {
const outcomeSet = new Set(filters.outcomes);
filtered = filtered.filter(c => c.latestOutcome && outcomeSet.has(c.latestOutcome));
}
if (filters.supportLevelMin !== undefined || filters.supportLevelMax !== undefined) {
const min = filters.supportLevelMin ?? 1;
const max = filters.supportLevelMax ?? 4;
filtered = filtered.filter(c => {
if (!c.supportLevel) return false;
const level = SUPPORT_LEVEL_ORDER[c.supportLevel] ?? 0;
return level >= min && level <= max;
});
}
if (filters.hasEmail === true) {
filtered = filtered.filter(c => !!c.email);
}
if (filters.hasSign === true) {
filtered = filtered.filter(c => c.sign);
}
if (filters.visitedSince) {
const since = new Date(filters.visitedSince);
filtered = filtered.filter(c => c.latestVisitDate && c.latestVisitDate >= since);
}
// Deduplicate by addressId (in case of overlapping cuts)
const seen = new Set<string>();
const deduped: ContactCandidate[] = [];
for (const c of filtered) {
if (!seen.has(c.addressId)) {
seen.add(c.addressId);
deduped.push(c);
}
}
return deduped.slice(0, MAX_EXPORT);
}
// --- Service Functions ---
export async function previewExportContacts(
filters: ExportContactsPreviewInput,
): Promise<ExportPreviewResult> {
const contacts = await resolveContactsFromCuts(filters);
const byCut: Record<string, { cutName: string; contacts: number; withEmail: number }> = {};
const byOutcome: Record<string, number> = {};
const bySupportLevel: Record<string, number> = {};
let withEmail = 0;
for (const c of contacts) {
// By cut
const cutKey = c.cutName;
if (!byCut[cutKey]) byCut[cutKey] = { cutName: cutKey, contacts: 0, withEmail: 0 };
byCut[cutKey].contacts++;
if (c.email) {
byCut[cutKey].withEmail++;
withEmail++;
}
// By outcome
if (c.latestOutcome) {
byOutcome[c.latestOutcome] = (byOutcome[c.latestOutcome] || 0) + 1;
}
// By support level
if (c.supportLevel) {
bySupportLevel[c.supportLevel] = (bySupportLevel[c.supportLevel] || 0) + 1;
}
}
// Map byCut to array with cutIds
const cuts = await prisma.cut.findMany({
where: { id: { in: filters.cutIds } },
select: { id: true, name: true },
});
const cutIdMap = new Map(cuts.map(c => [c.name, c.id]));
return {
totalContacts: contacts.length,
contactsWithEmail: withEmail,
byCut: Object.entries(byCut).map(([name, data]) => ({
cutId: cutIdMap.get(name) || '',
cutName: name,
contacts: data.contacts,
withEmail: data.withEmail,
})),
byOutcome,
bySupportLevel,
};
}
export async function exportContactsToCampaign(
filters: ExportContactsInput,
): Promise<ExportResult> {
const campaign = await prisma.campaign.findUnique({
where: { id: filters.campaignId },
select: { id: true, title: true, slug: true, allowCustomRecipients: true },
});
if (!campaign) {
throw new Error('Campaign not found');
}
// Auto-enable custom recipients if not already
if (!campaign.allowCustomRecipients) {
await prisma.campaign.update({
where: { id: campaign.id },
data: { allowCustomRecipients: true },
});
}
const contacts = await resolveContactsFromCuts(filters);
// Get existing custom recipients to deduplicate
const existing = await prisma.customRecipient.findMany({
where: { campaignId: campaign.id },
select: { recipientEmail: true },
});
const existingEmails = new Set(existing.map(r => r.recipientEmail.toLowerCase()));
let created = 0;
let skippedDuplicate = 0;
let skippedNoEmail = 0;
// Deduplicate by email across all contacts
const emailsSeen = new Set<string>();
const toCreate: Prisma.CustomRecipientCreateManyInput[] = [];
for (const c of contacts) {
if (!c.email) {
skippedNoEmail++;
continue;
}
const emailLower = c.email.toLowerCase();
if (existingEmails.has(emailLower) || emailsSeen.has(emailLower)) {
skippedDuplicate++;
continue;
}
emailsSeen.add(emailLower);
const name = [c.firstName, c.lastName].filter(Boolean).join(' ') || c.email;
const addrLabel = c.unitNumber
? `${c.locationAddress} Unit ${c.unitNumber}`
: c.locationAddress;
toCreate.push({
campaignId: campaign.id,
campaignSlug: campaign.slug,
recipientName: name,
recipientEmail: c.email,
notes: `From canvass: ${c.cutName}${addrLabel}${c.supportLevel ? ` (${c.supportLevel})` : ''}${c.latestOutcome ? ` [${c.latestOutcome}]` : ''}`,
});
}
if (toCreate.length > 0) {
await prisma.customRecipient.createMany({ data: toCreate });
created = toCreate.length;
}
logger.info(`Exported ${created} contacts to campaign "${campaign.title}" (${skippedDuplicate} dupes, ${skippedNoEmail} no email)`);
return {
created,
skippedDuplicate,
skippedNoEmail,
campaignId: campaign.id,
campaignTitle: campaign.title,
};
}
export async function getCutCampaignAnalytics(): Promise<CutCampaignAnalytics[]> {
const cuts = await prisma.cut.findMany({
select: { id: true, name: true, geojson: true },
orderBy: { name: 'asc' },
});
const results: CutCampaignAnalytics[] = [];
for (const cut of cuts) {
const polygons = parseGeoJsonPolygon(cut.geojson);
const allCoords = polygons.flat();
const bounds = calculateBounds(allCoords);
const locations = await prisma.location.findMany({
where: {
latitude: { gte: new Prisma.Decimal(bounds.minLat.toString()), lte: new Prisma.Decimal(bounds.maxLat.toString()) },
longitude: { gte: new Prisma.Decimal(bounds.minLng.toString()), lte: new Prisma.Decimal(bounds.maxLng.toString()) },
},
select: {
latitude: true,
longitude: true,
addresses: {
select: { id: true, email: true, supportLevel: true },
},
},
});
let totalAddresses = 0;
let addressesWithEmail = 0;
const addressIds: string[] = [];
const supportBreakdown: Record<string, number> = {};
for (const loc of locations) {
if (!polygons.some(p => isPointInPolygon(Number(loc.latitude), Number(loc.longitude), p))) continue;
for (const addr of loc.addresses) {
totalAddresses++;
addressIds.push(addr.id);
if (addr.email) addressesWithEmail++;
if (addr.supportLevel) {
supportBreakdown[addr.supportLevel] = (supportBreakdown[addr.supportLevel] || 0) + 1;
}
}
}
const visitedCount = addressIds.length > 0
? await prisma.canvassVisit.findMany({
where: { addressId: { in: addressIds } },
distinct: ['addressId'],
select: { addressId: true },
}).then(r => r.length)
: 0;
results.push({
cutId: cut.id,
cutName: cut.name,
totalAddresses,
visitedAddresses: visitedCount,
completionPct: totalAddresses > 0 ? Math.round((visitedCount / totalAddresses) * 100) : 0,
addressesWithEmail,
supportBreakdown,
});
}
return results;
}

View File

@ -7,6 +7,11 @@ import { recordLocationQuery } from '../../../utils/metrics';
import { isPointInPolygon, parseGeoJsonPolygon } from '../../../utils/spatial';
import { calculateWalkingRoute } from './canvass-route.service';
import { recordCanvassVisit, setActiveCanvassSessions } from '../../../utils/metrics';
import { notificationQueueService } from '../../../services/notification-queue.service';
import { getAdminEmailsByRole, isNotificationEnabled } from '../../../services/notification.helper';
import { env } from '../../../config/env';
import { rocketchatWebhookService } from '../../../services/rocketchat-webhook.service';
import { listmonkEventSyncService } from '../../../services/listmonk-event-sync.service';
import type {
RecordVisitInput,
BulkRecordVisitInput,
@ -237,6 +242,90 @@ export const canvassService = {
// Recalculate cut completion percentage
await this.recalculateCutCompletion(session.cutId);
// Notify Rocket.Chat
try {
const [rcUser, rcCut, rcVisitCount] = await Promise.all([
prisma.user.findUnique({ where: { id: userId }, select: { name: true, email: true } }),
prisma.cut.findUnique({ where: { id: session.cutId }, select: { name: true } }),
prisma.canvassVisit.count({ where: { sessionId } }),
]);
rocketchatWebhookService.onCanvassSessionCompleted({
userName: rcUser?.name || rcUser?.email || 'Unknown',
visitCount: rcVisitCount,
cutName: rcCut?.name || undefined,
}).catch(() => {});
} catch { /* non-critical */ }
// Notification: volunteer session summary
try {
if (await isNotificationEnabled('notifyVolunteerSessionSummary')) {
const [user, cut, visitCount, outcomeGroups, trackingSession] = await Promise.all([
prisma.user.findUnique({ where: { id: userId }, select: { email: true, name: true } }),
prisma.cut.findUnique({ where: { id: session.cutId }, select: { name: true } }),
prisma.canvassVisit.count({ where: { sessionId } }),
prisma.canvassVisit.groupBy({ by: ['outcome'], where: { sessionId }, _count: true }),
prisma.trackingSession.findFirst({
where: { userId, canvassSessionId: sessionId },
select: { totalDistanceM: true },
}),
]);
if (user && visitCount > 0) {
const durationMs = updated.endedAt
? updated.endedAt.getTime() - session.startedAt.getTime()
: 0;
const durationMinutes = Math.round(durationMs / 60000);
const distanceKm = trackingSession?.totalDistanceM
? Number(trackingSession.totalDistanceM) / 1000
: 0;
const outcomeBreakdown: Record<string, number> = {};
for (const row of outcomeGroups) {
outcomeBreakdown[row.outcome] = row._count;
}
await notificationQueueService.enqueue({
type: 'volunteer-session-summary',
volunteerEmail: user.email,
volunteerName: user.name || user.email,
cutName: cut?.name || 'Unknown area',
sessionDate: session.startedAt.toLocaleDateString('en-CA', {
weekday: 'long', year: 'numeric', month: 'long', day: 'numeric',
}),
visitCount,
durationMinutes,
distanceKm,
outcomeBreakdown,
});
}
}
} catch (err) {
logger.error('Failed to enqueue session summary notification:', err);
}
// Listmonk event sync — add canvasser to subscribers
try {
const [syncUser, syncCut, syncVisitCount, syncOutcomes] = await Promise.all([
prisma.user.findUnique({ where: { id: userId }, select: { email: true, name: true } }),
prisma.cut.findUnique({ where: { id: session.cutId }, select: { name: true } }),
prisma.canvassVisit.count({ where: { sessionId } }),
prisma.canvassVisit.groupBy({ by: ['outcome'], where: { sessionId }, _count: true }),
]);
if (syncUser) {
const outcomes: Record<string, number> = {};
for (const row of syncOutcomes) {
outcomes[row.outcome] = row._count;
}
listmonkEventSyncService.onCanvassSessionCompleted({
email: syncUser.email,
name: syncUser.name || syncUser.email,
cutName: syncCut?.name || 'Unknown',
visitCount: syncVisitCount,
outcomes,
}).catch(() => {});
}
} catch { /* non-critical */ }
return updated;
},
@ -549,6 +638,36 @@ export const canvassService = {
recordCanvassVisit(data.outcome);
// Notification: sign request alert for admins
if (data.signRequested) {
try {
if (await isNotificationEnabled('notifyAdminSignRequested')) {
const adminEmails = await getAdminEmailsByRole([UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN]);
if (adminEmails.length > 0) {
const [volunteer, shift] = await Promise.all([
prisma.user.findUnique({ where: { id: userId }, select: { name: true } }),
data.shiftId ? prisma.shift.findUnique({ where: { id: data.shiftId }, select: { title: true } }) : null,
]);
const addressStr = visit.address?.location?.address || 'Unknown address';
const adminUrl = `${env.ADMIN_URL || 'http://localhost:3000'}/app/canvass/dashboard`;
await notificationQueueService.enqueue({
type: 'admin-sign-requested',
adminEmails,
volunteerName: volunteer?.name || 'Unknown',
address: addressStr,
shiftTitle: shift?.title || 'No shift',
signSize: data.signSize || '',
adminUrl,
});
}
}
} catch (err) {
logger.error('Failed to enqueue sign request notification:', err);
}
}
return visit;
},

View File

@ -1,11 +1,16 @@
import bcrypt from 'bcryptjs';
import { Prisma, ShiftStatus, SignupStatus, SignupSource } from '@prisma/client';
import { Prisma, ShiftStatus, SignupStatus, SignupSource, UserRole } from '@prisma/client';
import { prisma } from '../../../config/database';
import { AppError } from '../../../middleware/error-handler';
import { emailService } from '../../../services/email.service';
import { notificationQueueService } from '../../../services/notification-queue.service';
import { getAdminEmailsByRole, isNotificationEnabled } from '../../../services/notification.helper';
import { env } from '../../../config/env';
import { logger } from '../../../utils/logger';
import { recordShiftSignup } from '../../../utils/metrics';
import { rocketchatWebhookService } from '../../../services/rocketchat-webhook.service';
import { listmonkEventSyncService } from '../../../services/listmonk-event-sync.service';
import { gancioClient } from '../../../services/gancio.client';
import type {
CreateShiftInput,
UpdateShiftInput,
@ -121,6 +126,27 @@ export const shiftsService = {
},
});
// Gancio event sync (fire-and-forget)
if (gancioClient.enabled) {
gancioClient.createEvent({
title: shift.title,
description: shift.description,
location: shift.location,
date: shift.date,
startTime: shift.startTime,
endTime: shift.endTime,
}).then(async (eventId) => {
if (eventId) {
await prisma.shift.update({
where: { id: shift.id },
data: { gancioEventId: eventId },
});
}
}).catch((err) => {
logger.warn('Gancio sync on shift create failed:', err);
});
}
return shift;
},
@ -150,6 +176,20 @@ export const shiftsService = {
data: updateData,
});
// Gancio event sync (fire-and-forget)
if (gancioClient.enabled && shift.gancioEventId) {
gancioClient.updateEvent(shift.gancioEventId, {
title: shift.title,
description: shift.description,
location: shift.location,
date: shift.date,
startTime: shift.startTime,
endTime: shift.endTime,
}).catch((err) => {
logger.warn('Gancio sync on shift update failed:', err);
});
}
return shift;
},
@ -159,6 +199,13 @@ export const shiftsService = {
throw new AppError(404, 'Shift not found', 'SHIFT_NOT_FOUND');
}
// Delete Gancio event before deleting shift (fire-and-forget)
if (gancioClient.enabled && existing.gancioEventId) {
gancioClient.deleteEvent(existing.gancioEventId).catch((err) => {
logger.warn('Gancio sync on shift delete failed:', err);
});
}
await prisma.shift.delete({ where: { id } });
},
@ -246,6 +293,14 @@ export const shiftsService = {
}),
]);
// Listmonk event sync
listmonkEventSyncService.onShiftSignup({
email: data.userEmail,
name: data.userName || data.userEmail,
shiftTitle: shift.title,
shiftDate: new Date(shift.date).toISOString().split('T')[0],
}).catch(() => {});
return signup;
},
@ -403,8 +458,74 @@ export const shiftsService = {
logger.error('Failed to send shift signup confirmation email:', err);
}
// Notify Rocket.Chat
const shiftDateStr = new Date(shift.date).toLocaleDateString('en-CA', { month: 'short', day: 'numeric' });
rocketchatWebhookService.onShiftSignup({
userName: data.name || data.email,
shiftTitle: shift.title,
shiftDate: shiftDateStr,
}).catch(() => {});
// Notification: admin shift signup alert
try {
if (await isNotificationEnabled('notifyAdminShiftSignup')) {
const adminEmails = await getAdminEmailsByRole([UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN]);
if (adminEmails.length > 0) {
const adminUrl = `${env.ADMIN_URL || 'http://localhost:3000'}/app/map/shifts`;
const shiftDate = new Date(shift.date);
const dateStr = shiftDate.toLocaleDateString('en-CA', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
await notificationQueueService.enqueue({
type: 'admin-shift-signup',
adminEmails,
shiftTitle: shift.title,
shiftDate: dateStr,
volunteerName: data.name,
volunteerEmail: data.email,
signupSource: 'Public Form',
adminUrl,
});
}
}
} catch (err) {
logger.error('Failed to enqueue admin shift signup notification:', err);
}
// Notification: schedule 24h pre-shift reminder
try {
if (await isNotificationEnabled('notifyVolunteerShiftReminder')) {
const shiftDatetime = new Date(shift.date);
const [startH, startM] = shift.startTime.split(':').map(Number);
shiftDatetime.setHours(startH || 0, startM || 0, 0, 0);
await notificationQueueService.scheduleShiftReminder({
type: 'volunteer-shift-reminder',
recipientEmail: data.email,
recipientName: data.name,
shiftTitle: shift.title,
shiftDate: shiftDatetime.toLocaleDateString('en-CA', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }),
shiftStartTime: shift.startTime,
shiftEndTime: shift.endTime,
shiftLocation: shift.location || 'TBD',
shiftDescription: shift.description || '',
currentVolunteers: shift.currentVolunteers + 1,
maxVolunteers: shift.maxVolunteers,
shiftStatus: shift.status,
}, shiftDatetime);
}
} catch (err) {
logger.error('Failed to schedule shift reminder:', err);
}
recordShiftSignup();
// Listmonk event sync
listmonkEventSyncService.onShiftSignup({
email: data.email,
name: data.name,
shiftTitle: shift.title,
shiftDate: new Date(shift.date).toISOString().split('T')[0],
}).catch(() => {});
return { signup, isNewUser };
},
@ -421,6 +542,8 @@ export const shiftsService = {
throw new AppError(400, 'Signup already cancelled', 'ALREADY_CANCELLED');
}
const shift = await prisma.shift.findUnique({ where: { id: shiftId } });
await prisma.$transaction([
prisma.shiftSignup.update({
where: { id: signup.id },
@ -434,6 +557,68 @@ export const shiftsService = {
},
}),
]);
// Notification: cancellation acknowledgement + cancel reminder
try {
if (shift && await isNotificationEnabled('notifyVolunteerCancellation')) {
const shiftDate = new Date(shift.date);
const dateStr = shiftDate.toLocaleDateString('en-CA', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
const signupUrl = `${env.CORS_ORIGINS.split(',')[0].trim()}/shifts`;
await notificationQueueService.enqueue({
type: 'volunteer-cancellation',
volunteerEmail: userEmail,
volunteerName: signup.userName || userEmail,
shiftTitle: shift.title,
shiftDate: dateStr,
shiftTime: `${shift.startTime}${shift.endTime}`,
signupUrl,
});
}
// Cancel the pending shift reminder
if (shift) {
const shiftDatetime = new Date(shift.date);
const [startH, startM] = shift.startTime.split(':').map(Number);
shiftDatetime.setHours(startH || 0, startM || 0, 0, 0);
await notificationQueueService.cancelShiftReminder(userEmail, shiftDatetime);
}
} catch (err) {
logger.error('Failed to enqueue cancellation notification:', err);
}
// Notify Rocket.Chat of cancellation
if (shift) {
const shiftDateStr = new Date(shift.date).toLocaleDateString('en-CA', { month: 'short', day: 'numeric' });
rocketchatWebhookService.onShiftCancellation({
userName: signup.userName || userEmail,
shiftTitle: shift.title,
shiftDate: shiftDateStr,
}).catch(() => {});
}
// Notification: admin shift cancellation alert
try {
if (shift && await isNotificationEnabled('notifyAdminShiftCancellation')) {
const adminEmails = await getAdminEmailsByRole([UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN]);
if (adminEmails.length > 0) {
const adminUrl = `${env.ADMIN_URL || 'http://localhost:3000'}/app/map/shifts`;
const shiftDate = new Date(shift.date);
const dateStr = shiftDate.toLocaleDateString('en-CA', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
await notificationQueueService.enqueue({
type: 'admin-shift-cancellation',
adminEmails,
shiftTitle: shift.title,
shiftDate: dateStr,
volunteerName: signup.userName || userEmail,
volunteerEmail: userEmail,
cancellationSource: 'Public Form',
adminUrl,
});
}
}
} catch (err) {
logger.error('Failed to enqueue admin shift cancellation notification:', err);
}
},
async getUpcomingForVolunteer(userId: string) {
@ -560,11 +745,77 @@ export const shiftsService = {
logger.error('Failed to send volunteer shift signup confirmation email:', err);
}
// Notify Rocket.Chat
const shiftDateStr = new Date(shift.date).toLocaleDateString('en-CA', { month: 'short', day: 'numeric' });
rocketchatWebhookService.onShiftSignup({
userName: user.name || user.email,
shiftTitle: shift.title,
shiftDate: shiftDateStr,
}).catch(() => {});
// Notification: admin shift signup alert
try {
if (await isNotificationEnabled('notifyAdminShiftSignup')) {
const adminEmails = await getAdminEmailsByRole([UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN]);
if (adminEmails.length > 0) {
const adminUrl = `${env.ADMIN_URL || 'http://localhost:3000'}/app/map/shifts`;
const shiftDate = new Date(shift.date);
const dateStr = shiftDate.toLocaleDateString('en-CA', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
await notificationQueueService.enqueue({
type: 'admin-shift-signup',
adminEmails,
shiftTitle: shift.title,
shiftDate: dateStr,
volunteerName: user.name || user.email,
volunteerEmail: user.email,
signupSource: 'Authenticated Volunteer',
adminUrl,
});
}
}
} catch (err) {
logger.error('Failed to enqueue admin shift signup notification:', err);
}
// Notification: schedule 24h pre-shift reminder
try {
if (await isNotificationEnabled('notifyVolunteerShiftReminder')) {
const shiftDatetime = new Date(shift.date);
const [startH, startM] = shift.startTime.split(':').map(Number);
shiftDatetime.setHours(startH || 0, startM || 0, 0, 0);
await notificationQueueService.scheduleShiftReminder({
type: 'volunteer-shift-reminder',
recipientEmail: user.email,
recipientName: user.name || user.email,
shiftTitle: shift.title,
shiftDate: shiftDatetime.toLocaleDateString('en-CA', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }),
shiftStartTime: shift.startTime,
shiftEndTime: shift.endTime,
shiftLocation: shift.location || 'TBD',
shiftDescription: shift.description || '',
currentVolunteers: shift.currentVolunteers + 1,
maxVolunteers: shift.maxVolunteers,
shiftStatus: shift.status,
}, shiftDatetime);
}
} catch (err) {
logger.error('Failed to schedule shift reminder:', err);
}
// Listmonk event sync
listmonkEventSyncService.onShiftSignup({
email: user.email,
name: user.name || user.email,
shiftTitle: shift.title,
shiftDate: new Date(shift.date).toISOString().split('T')[0],
}).catch(() => {});
return signup;
},
async cancelVolunteerSignup(shiftId: string, userId: string) {
const user = await prisma.user.findUnique({ where: { id: userId }, select: { email: true } });
const user = await prisma.user.findUnique({ where: { id: userId }, select: { email: true, name: true } });
if (!user) throw new AppError(404, 'User not found', 'USER_NOT_FOUND');
const signup = await prisma.shiftSignup.findUnique({
@ -574,6 +825,8 @@ export const shiftsService = {
if (!signup) throw new AppError(404, 'Signup not found', 'SIGNUP_NOT_FOUND');
if (signup.status === SignupStatus.CANCELLED) throw new AppError(400, 'Already cancelled', 'ALREADY_CANCELLED');
const shift = await prisma.shift.findUnique({ where: { id: shiftId } });
await prisma.$transaction([
prisma.shiftSignup.update({
where: { id: signup.id },
@ -587,6 +840,68 @@ export const shiftsService = {
},
}),
]);
// Notification: cancellation acknowledgement + cancel reminder
try {
if (shift && await isNotificationEnabled('notifyVolunteerCancellation')) {
const shiftDate = new Date(shift.date);
const dateStr = shiftDate.toLocaleDateString('en-CA', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
const signupUrl = `${env.CORS_ORIGINS.split(',')[0].trim()}/shifts`;
await notificationQueueService.enqueue({
type: 'volunteer-cancellation',
volunteerEmail: user.email,
volunteerName: user.name || user.email,
shiftTitle: shift.title,
shiftDate: dateStr,
shiftTime: `${shift.startTime}${shift.endTime}`,
signupUrl,
});
}
// Cancel the pending shift reminder
if (shift) {
const shiftDatetime = new Date(shift.date);
const [startH, startM] = shift.startTime.split(':').map(Number);
shiftDatetime.setHours(startH || 0, startM || 0, 0, 0);
await notificationQueueService.cancelShiftReminder(user.email, shiftDatetime);
}
} catch (err) {
logger.error('Failed to enqueue cancellation notification:', err);
}
// Notify Rocket.Chat of cancellation
if (shift) {
const shiftDateStr = new Date(shift.date).toLocaleDateString('en-CA', { month: 'short', day: 'numeric' });
rocketchatWebhookService.onShiftCancellation({
userName: user.name || user.email,
shiftTitle: shift.title,
shiftDate: shiftDateStr,
}).catch(() => {});
}
// Notification: admin shift cancellation alert
try {
if (shift && await isNotificationEnabled('notifyAdminShiftCancellation')) {
const adminEmails = await getAdminEmailsByRole([UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN]);
if (adminEmails.length > 0) {
const adminUrl = `${env.ADMIN_URL || 'http://localhost:3000'}/app/map/shifts`;
const shiftDate = new Date(shift.date);
const dateStr = shiftDate.toLocaleDateString('en-CA', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' });
await notificationQueueService.enqueue({
type: 'admin-shift-cancellation',
adminEmails,
shiftTitle: shift.title,
shiftDate: dateStr,
volunteerName: user.name || user.email,
volunteerEmail: user.email,
cancellationSource: 'Volunteer Portal',
adminUrl,
});
}
}
} catch (err) {
logger.error('Failed to enqueue admin shift cancellation notification:', err);
}
},
async getMySignups(userId: string) {

View File

@ -0,0 +1,81 @@
import { Router, Request, Response, NextFunction } from 'express';
import { authenticate } from '../../middleware/auth.middleware';
import { requireNonTemp } from '../../middleware/rbac.middleware';
import { env } from '../../config/env';
import { logger } from '../../utils/logger';
import { rocketchatClient } from '../../services/rocketchat.client';
import { rocketchatService } from './rocketchat.service';
import { siteSettingsService } from '../settings/settings.service';
const router = Router();
/** Check if chat is enabled (DB setting wins, env var is fallback for first boot) */
async function isChatEnabled(): Promise<boolean> {
try {
const settings = await siteSettingsService.get();
// DB setting is authoritative; env var is the initial default
return settings.enableChat;
} catch {
return env.ENABLE_CHAT === 'true';
}
}
// GET /api/rocketchat/status — health check (any authenticated user)
router.get(
'/status',
authenticate,
async (_req: Request, res: Response, next: NextFunction) => {
try {
const enabled = await isChatEnabled();
const online = enabled ? await rocketchatClient.healthCheck() : false;
res.json({ online, enabled });
} catch (err) {
logger.error('RC status check failed:', err);
next(err);
}
},
);
// GET /api/rocketchat/config — return RC URLs + enabled status
router.get(
'/config',
authenticate,
async (_req: Request, res: Response, _next: NextFunction) => {
const enabled = await isChatEnabled();
res.json({
enabled,
embedPort: env.ROCKETCHAT_EMBED_PORT,
subdomain: 'chat',
domain: env.DOMAIN,
});
},
);
// POST /api/rocketchat/auth — get RC session token for current user (SSO)
router.post(
'/auth',
authenticate,
requireNonTemp,
async (req: Request, res: Response, next: NextFunction) => {
try {
const enabled = await isChatEnabled();
if (!enabled) {
res.status(400).json({ error: 'Chat is not enabled' });
return;
}
const userId = req.user!.id;
const tokenData = await rocketchatService.getAuthToken(userId);
res.json({
authToken: tokenData.authToken,
rcUserId: tokenData.rcUserId,
});
} catch (err) {
logger.error('RC auth failed:', err);
next(err);
}
},
);
export const rocketchatRouter = router;

View File

@ -0,0 +1,129 @@
import { createHmac } from 'crypto';
import { prisma } from '../../config/database';
import { env } from '../../config/env';
import { logger } from '../../utils/logger';
import { rocketchatClient } from '../../services/rocketchat.client';
// Changemaker role → Rocket.Chat role mapping
const ROLE_MAP: Record<string, string[]> = {
SUPER_ADMIN: ['admin'],
INFLUENCE_ADMIN: ['moderator'],
MAP_ADMIN: ['moderator'],
USER: ['user'],
TEMP: ['user'],
};
/**
* Generate a deterministic password for a Rocket.Chat user.
* Never exposed to users only used for RC internal auth.
*/
function generateRCPassword(userId: string): string {
return createHmac('sha256', env.JWT_ACCESS_SECRET)
.update(`rc:${userId}`)
.digest('hex');
}
/**
* Generate a safe username from email, with collision avoidance suffix.
*/
function generateUsername(email: string, suffix = 0): string {
const base = email.split('@')[0].toLowerCase().replace(/[^a-z0-9._-]/g, '');
return suffix > 0 ? `${base}${suffix}` : base;
}
class RocketChatService {
/**
* Get a Rocket.Chat auth token for the given Changemaker user.
* Provisions / syncs the RC user as needed.
*/
async getAuthToken(changemakerUserId: string): Promise<{
authToken: string;
rcUserId: string;
}> {
// 1. Look up Changemaker user
const user = await prisma.user.findUnique({
where: { id: changemakerUserId },
});
if (!user) throw new Error('User not found');
// 2. Check for cached RC user ID in permissions JSON
const permissions = (user.permissions as Record<string, unknown>) || {};
let rcUserId = permissions._rcUserId as string | undefined;
if (rcUserId) {
// Sync roles on every access
const rcRoles = ROLE_MAP[user.role] || ['user'];
try {
await rocketchatClient.updateUser(rcUserId, {
name: user.name || user.email.split('@')[0],
roles: rcRoles,
});
} catch (err) {
logger.warn('RC role sync failed, continuing:', err);
}
} else {
// 3. Find or create RC user
let rcUser = await rocketchatClient.findUserByEmail(user.email);
if (!rcUser) {
// Generate unique username with collision handling
let username = generateUsername(user.email);
let suffix = 0;
const maxAttempts = 5;
while (suffix < maxAttempts) {
try {
rcUser = await rocketchatClient.createUser({
email: user.email,
name: user.name || user.email.split('@')[0],
username,
password: generateRCPassword(user.id),
roles: ROLE_MAP[user.role] || ['user'],
});
break;
} catch (err) {
if (err instanceof Error && err.message.includes('already in use')) {
suffix++;
username = generateUsername(user.email, suffix);
} else {
throw err;
}
}
}
if (!rcUser) throw new Error('Failed to create RC user after retries');
}
rcUserId = rcUser._id;
// 4. Cache RC user ID in permissions JSON (no migration needed)
await prisma.user.update({
where: { id: user.id },
data: {
permissions: { ...permissions, _rcUserId: rcUserId },
},
});
}
// 5. Generate login token
const tokenData = await rocketchatClient.createUserToken(rcUserId);
return {
authToken: tokenData.authToken,
rcUserId: tokenData.userId,
};
}
/**
* Setup default channels on first use
*/
async ensureDefaultChannels(): Promise<void> {
try {
await rocketchatClient.ensureChannel('shifts', 'Shift coordination and updates');
await rocketchatClient.ensureChannel('canvassing', 'Canvass activity and updates');
await rocketchatClient.ensureChannel('campaigns', 'Campaign activity and responses');
logger.info('RC default channels verified');
} catch (err) {
logger.warn('RC ensureDefaultChannels failed:', err);
}
}
}
export const rocketchatService = new RocketChatService();

View File

@ -17,7 +17,7 @@ router.get(
'/status',
async (_req: Request, res: Response, next: NextFunction) => {
try {
const [nocodbOnline, n8nOnline, giteaOnline, mailhogOnline, miniqrOnline, excalidrawOnline, homepageOnline] = await Promise.all([
const [nocodbOnline, n8nOnline, giteaOnline, mailhogOnline, miniqrOnline, excalidrawOnline, homepageOnline, vaultwardenOnline, rocketchatOnline, gancioOnline] = await Promise.all([
isServiceOnline(env.NOCODB_URL),
isServiceOnline(env.N8N_URL),
isServiceOnline(env.GITEA_URL),
@ -25,6 +25,9 @@ router.get(
isServiceOnline(env.MINI_QR_URL),
isServiceOnline(env.EXCALIDRAW_URL),
isServiceOnline(env.HOMEPAGE_URL),
isServiceOnline(env.VAULTWARDEN_URL),
isServiceOnline(`${env.ROCKETCHAT_URL}/api/info`),
isServiceOnline(env.GANCIO_URL),
]);
// Update Prometheus gauges
@ -35,6 +38,9 @@ router.get(
setServiceUp('miniqr', miniqrOnline);
setServiceUp('excalidraw', excalidrawOnline);
setServiceUp('homepage', homepageOnline);
setServiceUp('vaultwarden', vaultwardenOnline);
setServiceUp('rocketchat', rocketchatOnline);
setServiceUp('gancio', gancioOnline);
res.json({
nocodb: { online: nocodbOnline, url: env.NOCODB_URL },
@ -44,6 +50,9 @@ router.get(
miniqr: { online: miniqrOnline, url: env.MINI_QR_URL },
excalidraw: { online: excalidrawOnline, url: env.EXCALIDRAW_URL },
homepage: { online: homepageOnline, url: env.HOMEPAGE_URL },
vaultwarden: { online: vaultwardenOnline, url: env.VAULTWARDEN_URL },
rocketchat: { online: rocketchatOnline, url: env.ROCKETCHAT_URL },
gancio: { online: gancioOnline, url: env.GANCIO_URL },
});
} catch (err) {
logger.error('Failed to check services status', err);
@ -88,6 +97,15 @@ router.get(
// Homepage (service dashboard)
homepagePort: env.HOMEPAGE_EMBED_PORT,
homepageSubdomain: 'home',
// Vaultwarden (password manager)
vaultwardenPort: env.VAULTWARDEN_EMBED_PORT,
vaultwardenSubdomain: 'vault',
// Rocket.Chat (team chat)
rocketchatPort: env.ROCKETCHAT_EMBED_PORT,
rocketchatSubdomain: 'chat',
// Gancio (event management)
gancioPort: env.GANCIO_EMBED_PORT,
gancioSubdomain: 'events',
});
},
);

View File

@ -49,6 +49,17 @@ export const updateSiteSettingsSchema = z.object({
enableMediaFeatures: z.boolean().optional(),
enablePayments: z.boolean().optional(),
enableGalleryAds: z.boolean().optional(),
enableChat: z.boolean().optional(),
enableEvents: z.boolean().optional(),
// Notification settings
notifyAdminShiftSignup: z.boolean().optional(),
notifyAdminResponseSubmitted: z.boolean().optional(),
notifyAdminSignRequested: z.boolean().optional(),
notifyAdminShiftCancellation: z.boolean().optional(),
notifyVolunteerSessionSummary: z.boolean().optional(),
notifyVolunteerCancellation: z.boolean().optional(),
notifyVolunteerShiftReminder: z.boolean().optional(),
});
export type UpdateSiteSettingsInput = z.infer<typeof updateSiteSettingsSchema>;

View File

@ -27,6 +27,7 @@ import shiftSeriesRouter from './modules/map/shifts/shift-series.routes';
import { mapSettingsRouter } from './modules/map/settings/settings.routes';
import { qrRouter } from './modules/qr/qr.routes';
import { listmonkRouter } from './modules/listmonk/listmonk.routes';
import { listmonkWebhookRouter } from './modules/listmonk/listmonk-webhook.routes';
import { pagesPublicRouter } from './modules/pages/pages-public.routes';
import { pagesAdminRouter } from './modules/pages/pages-admin.routes';
import { blocksRouter } from './modules/pages/blocks.routes';
@ -34,9 +35,12 @@ import { docsRouter } from './modules/docs/docs.routes';
import { servicesRouter } from './modules/services/services.routes';
import { siteSettingsRouter } from './modules/settings/settings.routes';
import { canvassVolunteerRouter, canvassAdminRouter } from './modules/map/canvass/canvass.routes';
import { canvassExportRouter } from './modules/map/canvass/canvass-export.routes';
import { trackingVolunteerRouter, trackingAdminRouter } from './modules/map/tracking/tracking.routes';
import { geocodingRouter } from './modules/map/geocoding/geocoding.routes';
import { pangolinRouter } from './modules/pangolin/pangolin.routes';
import { rocketchatRouter } from './modules/rocketchat/rocketchat.routes';
import { rocketchatWebhookService } from './services/rocketchat-webhook.service';
import { narImportRouter } from './modules/map/locations/nar-import.routes';
import { areaImportRouter } from './modules/map/locations/area-import.routes';
import emailTemplatesRouter from './modules/email-templates/email-templates-admin.routes';
@ -45,6 +49,7 @@ import { dashboardRouter } from './modules/dashboard/dashboard.routes';
import { initEncryption } from './utils/crypto';
import { emailService } from './services/email.service';
import { emailQueueService } from './services/email-queue.service';
import { notificationQueueService } from './services/notification-queue.service';
import { geocodeQueueService } from './services/geocode-queue.service';
import { startProxy, stopProxy } from './services/listmonk-proxy.service';
import { pagesService } from './modules/pages/pages.service';
@ -167,6 +172,7 @@ app.use('/api/map/shifts', shiftsAdminRouter); // Admin shift CRUD (au
app.use('/api/map/geocoding', geocodingRouter); // Geocoding search (MAP_ADMIN+)
app.use('/api/map/settings', mapSettingsRouter); // Map settings (public GET, auth PUT)
app.use('/api/qr', qrRouter); // QR code generation (public)
app.use('/api/listmonk', listmonkWebhookRouter); // Listmonk webhook (shared secret, no JWT)
app.use('/api/listmonk', listmonkRouter); // Listmonk newsletter sync (SUPER_ADMIN)
app.use('/api/email-templates', emailTemplatesRouter); // Email template management (ADMIN roles)
app.use('/api/pages', pagesPublicRouter); // Public landing pages (no auth)
@ -176,10 +182,12 @@ app.use('/api/docs', docsRouter); // Docs status + config
app.use('/api/services', servicesRouter); // Platform services status (SUPER_ADMIN)
app.use('/api/map/canvass', canvassVolunteerRouter); // Volunteer canvass routes (auth required)
app.use('/api/map/canvass', canvassAdminRouter); // Admin canvass routes (MAP_ADMIN+)
app.use('/api/map/canvass', canvassExportRouter); // Canvass-to-campaign export (admin roles)
app.use('/api/map/tracking', trackingVolunteerRouter); // Volunteer GPS tracking (auth required)
app.use('/api/map/tracking', trackingAdminRouter); // Admin GPS tracking (MAP_ADMIN+)
app.use('/api/settings', siteSettingsRouter); // Site settings (public GET, SUPER_ADMIN PUT)
app.use('/api/pangolin', pangolinRouter); // Pangolin tunnel management (SUPER_ADMIN)
app.use('/api/rocketchat', rocketchatRouter); // Rocket.Chat SSO + status (auth required)
app.use('/api/observability', observabilityRouter); // Observability / monitoring (SUPER_ADMIN)
app.use('/api/dashboard', dashboardRouter); // Dashboard summary (ADMIN roles)
app.use('/api/payments', paymentsPublicRouter); // Public payment routes (plans, checkout, my subscription)
@ -210,6 +218,7 @@ async function start() {
await emailService.rebuildTransporter();
emailQueueService.startWorker();
notificationQueueService.startWorker();
geocodeQueueService.startWorker();
startProxy();
@ -239,6 +248,9 @@ async function start() {
docsAnalyticsService.cleanupOldData(90).catch(() => {});
setInterval(() => docsAnalyticsService.cleanupOldData(90).catch(() => {}), 24 * 60 * 60 * 1000);
// Setup Rocket.Chat notification channels (non-blocking)
rocketchatWebhookService.setupChannels().catch(() => {});
// Sync MkDocs overrides on startup
pagesService.syncOverrides()
.then(({ imported, updated }) => {
@ -283,6 +295,7 @@ for (const signal of ['SIGTERM', 'SIGINT']) {
logger.info(`${signal} received, shutting down...`);
await stopProxy();
await emailQueueService.close();
await notificationQueueService.close();
await geocodeQueueService.close();
await prisma.$disconnect();
redis.disconnect();

View File

@ -5,6 +5,7 @@ import { prisma } from '../config/database';
import { logger } from '../utils/logger';
import { emailService } from './email.service';
import { recordEmailSent, recordEmailFailed, setEmailQueueSize, emailSendDuration } from '../utils/metrics';
import { listmonkEventSyncService } from './listmonk-event-sync.service';
interface CampaignEmailJobData {
campaignEmailId: string;
@ -65,6 +66,13 @@ class EmailQueueService {
if (result.success) {
recordEmailSent(campaignId);
// Listmonk event sync
listmonkEventSyncService.onCampaignEmailSent({
email: emailData.userEmail,
name: emailData.userName,
campaignSlug: emailData.campaignTitle,
postalCode: emailData.postalCode,
}).catch(() => {});
} else {
recordEmailFailed(campaignId, 'send_failure');
throw new Error(`Failed to send email to ${emailData.recipientEmail}`);

View File

@ -748,6 +748,260 @@ class EmailService {
});
}
// ─── Notification Emails ────────────────────────────────────────────
async sendAdminShiftSignupAlert(options: {
adminEmails: string[];
shiftTitle: string;
shiftDate: string;
volunteerName: string;
volunteerEmail: string;
signupSource: string;
adminUrl: string;
}): Promise<void> {
const orgName = await this.getOrganizationName();
const vars: Record<string, string> = {
ORGANIZATION_NAME: orgName,
SHIFT_TITLE: options.shiftTitle,
SHIFT_DATE: options.shiftDate,
VOLUNTEER_NAME: options.volunteerName,
VOLUNTEER_EMAIL: options.volunteerEmail,
SIGNUP_SOURCE: options.signupSource,
ADMIN_URL: options.adminUrl,
};
const dbTemplate = await this.loadTemplateFromDatabase('admin-shift-signup-alert');
let html: string, text: string, subject: string;
if (dbTemplate) {
html = await this.processTemplate(dbTemplate.html, vars);
text = await this.processTextTemplate(dbTemplate.text, vars);
subject = this.processSubject(dbTemplate.subject, vars);
} else {
const htmlTemplate = this.loadTemplate('admin-shift-signup-alert', 'html');
const txtTemplate = this.loadTemplate('admin-shift-signup-alert', 'txt');
html = await this.processTemplate(htmlTemplate, vars);
text = await this.processTextTemplate(txtTemplate, vars);
subject = `New shift signup — ${options.shiftTitle}`;
}
for (const email of options.adminEmails) {
await this.sendEmail({ to: email, subject, html, text });
}
}
async sendAdminShiftCancellationAlert(options: {
adminEmails: string[];
shiftTitle: string;
shiftDate: string;
volunteerName: string;
volunteerEmail: string;
cancellationSource: string;
adminUrl: string;
}): Promise<void> {
const orgName = await this.getOrganizationName();
const vars: Record<string, string> = {
ORGANIZATION_NAME: orgName,
SHIFT_TITLE: options.shiftTitle,
SHIFT_DATE: options.shiftDate,
VOLUNTEER_NAME: options.volunteerName,
VOLUNTEER_EMAIL: options.volunteerEmail,
CANCELLATION_SOURCE: options.cancellationSource,
ADMIN_URL: options.adminUrl,
};
const dbTemplate = await this.loadTemplateFromDatabase('admin-shift-cancellation-alert');
let html: string, text: string, subject: string;
if (dbTemplate) {
html = await this.processTemplate(dbTemplate.html, vars);
text = await this.processTextTemplate(dbTemplate.text, vars);
subject = this.processSubject(dbTemplate.subject, vars);
} else {
const htmlTemplate = this.loadTemplate('admin-shift-cancellation-alert', 'html');
const txtTemplate = this.loadTemplate('admin-shift-cancellation-alert', 'txt');
html = await this.processTemplate(htmlTemplate, vars);
text = await this.processTextTemplate(txtTemplate, vars);
subject = `Shift cancellation — ${options.shiftTitle}`;
}
for (const email of options.adminEmails) {
await this.sendEmail({ to: email, subject, html, text });
}
}
async sendAdminResponseSubmittedAlert(options: {
adminEmails: string[];
campaignTitle: string;
representativeName: string;
responseType: string;
submitterName: string;
adminUrl: string;
}): Promise<void> {
const orgName = await this.getOrganizationName();
const vars: Record<string, string> = {
ORGANIZATION_NAME: orgName,
CAMPAIGN_TITLE: options.campaignTitle,
REPRESENTATIVE_NAME: options.representativeName,
RESPONSE_TYPE: options.responseType.replace(/_/g, ' '),
SUBMITTER_NAME: options.submitterName,
ADMIN_URL: options.adminUrl,
};
const dbTemplate = await this.loadTemplateFromDatabase('admin-response-submitted-alert');
let html: string, text: string, subject: string;
if (dbTemplate) {
html = await this.processTemplate(dbTemplate.html, vars);
text = await this.processTextTemplate(dbTemplate.text, vars);
subject = this.processSubject(dbTemplate.subject, vars);
} else {
const htmlTemplate = this.loadTemplate('admin-response-submitted-alert', 'html');
const txtTemplate = this.loadTemplate('admin-response-submitted-alert', 'txt');
html = await this.processTemplate(htmlTemplate, vars);
text = await this.processTextTemplate(txtTemplate, vars);
subject = `New response submitted — ${options.campaignTitle}`;
}
for (const email of options.adminEmails) {
await this.sendEmail({ to: email, subject, html, text });
}
}
async sendAdminSignRequestedAlert(options: {
adminEmails: string[];
volunteerName: string;
address: string;
shiftTitle: string;
signSize: string;
adminUrl: string;
}): Promise<void> {
const orgName = await this.getOrganizationName();
const vars: Record<string, string> = {
ORGANIZATION_NAME: orgName,
VOLUNTEER_NAME: options.volunteerName,
ADDRESS: options.address,
SHIFT_TITLE: options.shiftTitle,
SIGN_SIZE: options.signSize || 'Not specified',
ADMIN_URL: options.adminUrl,
};
const dbTemplate = await this.loadTemplateFromDatabase('admin-sign-requested-alert');
let html: string, text: string, subject: string;
if (dbTemplate) {
html = await this.processTemplate(dbTemplate.html, vars);
text = await this.processTextTemplate(dbTemplate.text, vars);
subject = this.processSubject(dbTemplate.subject, vars);
} else {
const htmlTemplate = this.loadTemplate('admin-sign-requested-alert', 'html');
const txtTemplate = this.loadTemplate('admin-sign-requested-alert', 'txt');
html = await this.processTemplate(htmlTemplate, vars);
text = await this.processTextTemplate(txtTemplate, vars);
subject = `Sign requested — ${options.address}`;
}
for (const email of options.adminEmails) {
await this.sendEmail({ to: email, subject, html, text });
}
}
async sendVolunteerSessionSummary(options: {
volunteerEmail: string;
volunteerName: string;
cutName: string;
sessionDate: string;
visitCount: number;
durationMinutes: number;
distanceKm: number;
outcomeBreakdown: Record<string, number>;
}): Promise<void> {
const orgName = await this.getOrganizationName();
// Build outcome breakdown as HTML table and plain text list
const outcomeEntries = Object.entries(options.outcomeBreakdown);
let outcomeHtml = '';
let outcomeText = '';
if (outcomeEntries.length > 0) {
outcomeHtml = '<table class="outcome-table"><tr><th>Outcome</th><th>Count</th></tr>';
outcomeText = 'Outcome Breakdown:\n';
for (const [outcome, count] of outcomeEntries) {
const label = outcome.replace(/_/g, ' ');
outcomeHtml += `<tr><td>${this.escapeHtml(label)}</td><td>${count}</td></tr>`;
outcomeText += ` ${label}: ${count}\n`;
}
outcomeHtml += '</table>';
}
const vars: Record<string, string> = {
ORGANIZATION_NAME: orgName,
VOLUNTEER_NAME: options.volunteerName,
CUT_NAME: options.cutName,
SESSION_DATE: options.sessionDate,
VISIT_COUNT: options.visitCount.toString(),
DURATION_MINUTES: options.durationMinutes.toString(),
DISTANCE_KM: options.distanceKm.toFixed(1),
OUTCOME_BREAKDOWN: outcomeHtml,
};
const dbTemplate = await this.loadTemplateFromDatabase('volunteer-session-summary');
let html: string, text: string, subject: string;
if (dbTemplate) {
html = await this.processTemplate(dbTemplate.html, vars);
// Use plain text breakdown for text version
vars.OUTCOME_BREAKDOWN = outcomeText;
text = await this.processTextTemplate(dbTemplate.text, vars);
subject = this.processSubject(dbTemplate.subject, vars);
} else {
const htmlTemplate = this.loadTemplate('volunteer-session-summary', 'html');
const txtTemplate = this.loadTemplate('volunteer-session-summary', 'txt');
html = await this.processTemplate(htmlTemplate, vars);
vars.OUTCOME_BREAKDOWN = outcomeText;
text = await this.processTextTemplate(txtTemplate, vars);
subject = `Canvass session summary — ${options.cutName}`;
}
await this.sendEmail({ to: options.volunteerEmail, subject, html, text });
}
async sendVolunteerCancellationAck(options: {
volunteerEmail: string;
volunteerName: string;
shiftTitle: string;
shiftDate: string;
shiftTime: string;
signupUrl: string;
}): Promise<void> {
const orgName = await this.getOrganizationName();
const vars: Record<string, string> = {
ORGANIZATION_NAME: orgName,
VOLUNTEER_NAME: options.volunteerName,
SHIFT_TITLE: options.shiftTitle,
SHIFT_DATE: options.shiftDate,
SHIFT_TIME: options.shiftTime,
SIGNUP_URL: options.signupUrl,
};
const dbTemplate = await this.loadTemplateFromDatabase('volunteer-cancellation-ack');
let html: string, text: string, subject: string;
if (dbTemplate) {
html = await this.processTemplate(dbTemplate.html, vars);
text = await this.processTextTemplate(dbTemplate.text, vars);
subject = this.processSubject(dbTemplate.subject, vars);
} else {
const htmlTemplate = this.loadTemplate('volunteer-cancellation-ack', 'html');
const txtTemplate = this.loadTemplate('volunteer-cancellation-ack', 'txt');
html = await this.processTemplate(htmlTemplate, vars);
text = await this.processTextTemplate(txtTemplate, vars);
subject = `Signup cancelled — ${options.shiftTitle}`;
}
await this.sendEmail({ to: options.volunteerEmail, subject, html, text });
}
async sendResponseVerification(options: {
recipientEmail: string;
campaignTitle: string;

View File

@ -0,0 +1,246 @@
import { env } from '../config/env';
import { logger } from '../utils/logger';
// --- Types ---
export interface GancioEvent {
id: number;
title: string;
description: string;
place_name: string;
place_address: string;
start_datetime: number; // Unix timestamp
end_datetime?: number;
tags: string[];
}
interface GancioLoginResponse {
access_token: string;
token_type: string;
}
// --- Client ---
class GancioClient {
private accessToken: string | null = null;
private tokenExpiresAt = 0;
private get baseUrl(): string {
return env.GANCIO_URL;
}
get enabled(): boolean {
return env.GANCIO_SYNC_ENABLED === 'true' && !!env.GANCIO_ADMIN_PASSWORD;
}
/**
* Authenticate with Gancio OAuth password grant, cache token for 1 hour.
* Gancio uses POST /oauth/login with application/x-www-form-urlencoded body
* (oauth2orize standard). Fields: username, password, client_id="self", grant_type="password".
*/
private async login(): Promise<void> {
if (this.accessToken && Date.now() < this.tokenExpiresAt) return;
const url = `${this.baseUrl}/oauth/login`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
// Gancio's oauth2orize endpoint requires URL-encoded form data (not JSON)
const formBody = new URLSearchParams({
username: env.GANCIO_ADMIN_USER,
password: env.GANCIO_ADMIN_PASSWORD,
client_id: 'self',
grant_type: 'password',
});
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: formBody.toString(),
signal: controller.signal,
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Gancio login failed (${res.status}): ${text}`);
}
const data = await res.json() as GancioLoginResponse;
this.accessToken = data.access_token;
// Cache for 1 hour
this.tokenExpiresAt = Date.now() + 60 * 60 * 1000;
logger.debug('Gancio auth token refreshed');
} finally {
clearTimeout(timeout);
}
}
/**
* Make an authenticated JSON request to the Gancio API
*/
private async request<T>(
method: string,
path: string,
body?: Record<string, unknown>,
): Promise<T> {
await this.login();
const url = `${this.baseUrl}${path}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
const headers: Record<string, string> = {};
if (this.accessToken) {
headers['Authorization'] = `Bearer ${this.accessToken}`;
}
let fetchBody: string | undefined;
if (body) {
headers['Content-Type'] = 'application/json';
fetchBody = JSON.stringify(body);
}
try {
const res = await fetch(url, {
method,
headers,
body: fetchBody,
signal: controller.signal,
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`Gancio API ${method} ${path} returned ${res.status}: ${text}`);
}
const contentType = res.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
return await res.json() as T;
}
return {} as T;
} finally {
clearTimeout(timeout);
}
}
// --- Health ---
async isAvailable(): Promise<boolean> {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(`${this.baseUrl}/api/events`, {
signal: controller.signal,
});
return res.ok;
} finally {
clearTimeout(timeout);
}
} catch {
return false;
}
}
// --- Event CRUD ---
/**
* Create a Gancio event from a shift
*/
async createEvent(shift: {
title: string;
description?: string | null;
location?: string | null;
date: Date;
startTime: string;
endTime: string;
}): Promise<number | null> {
if (!this.enabled) return null;
try {
const startDatetime = this.buildTimestamp(shift.date, shift.startTime);
const endDatetime = this.buildTimestamp(shift.date, shift.endTime);
const placeName = shift.location || 'TBD';
const event = await this.request<GancioEvent>('POST', '/api/event', {
title: shift.title,
description: shift.description || '',
place_name: placeName,
place_address: shift.location || placeName,
start_datetime: startDatetime,
end_datetime: endDatetime,
tags: ['volunteer', 'shift'],
});
logger.info(`Gancio: created event ${event.id} for shift "${shift.title}"`);
return event.id;
} catch (err) {
logger.warn('Gancio createEvent failed:', err instanceof Error ? err.message : err);
return null;
}
}
/**
* Update an existing Gancio event
*/
async updateEvent(eventId: number, shift: {
title: string;
description?: string | null;
location?: string | null;
date: Date;
startTime: string;
endTime: string;
}): Promise<void> {
if (!this.enabled) return;
try {
const startDatetime = this.buildTimestamp(shift.date, shift.startTime);
const endDatetime = this.buildTimestamp(shift.date, shift.endTime);
const placeName = shift.location || 'TBD';
await this.request('PUT', '/api/event', {
id: eventId,
title: shift.title,
description: shift.description || '',
place_name: placeName,
place_address: shift.location || placeName,
start_datetime: startDatetime,
end_datetime: endDatetime,
tags: ['volunteer', 'shift'],
});
logger.info(`Gancio: updated event ${eventId}`);
} catch (err) {
logger.warn(`Gancio updateEvent(${eventId}) failed:`, err instanceof Error ? err.message : err);
}
}
/**
* Delete a Gancio event
*/
async deleteEvent(eventId: number): Promise<void> {
if (!this.enabled) return;
try {
await this.request('DELETE', `/api/event/${eventId}`);
logger.info(`Gancio: deleted event ${eventId}`);
} catch (err) {
logger.warn(`Gancio deleteEvent(${eventId}) failed:`, err instanceof Error ? err.message : err);
}
}
// --- Helpers ---
/**
* Combine a Date and "HH:MM" time string into a Unix timestamp (seconds)
*/
private buildTimestamp(date: Date, time: string): number {
const d = new Date(date);
const [h, m] = time.split(':').map(Number);
d.setHours(h || 0, m || 0, 0, 0);
return Math.floor(d.getTime() / 1000);
}
}
export const gancioClient = new GancioClient();

View File

@ -0,0 +1,153 @@
import { env } from '../config/env';
import { logger } from '../utils/logger';
import { listmonkClient } from './listmonk.client';
import { listmonkSyncService } from './listmonk-sync.service';
/**
* Event-driven Listmonk sync fire-and-forget subscriber upserts
* triggered by application events (shift signups, canvass completions, campaign emails).
*
* All methods silently fail if LISTMONK_SYNC_ENABLED is false or Listmonk is unreachable.
*/
class ListmonkEventSyncService {
private _lastSyncAt: Date | null = null;
private _todaySyncCount = 0;
private _todayDate = '';
private get enabled(): boolean {
return env.LISTMONK_SYNC_ENABLED === 'true';
}
private incrementCounter(): void {
const today = new Date().toISOString().split('T')[0];
if (today !== this._todayDate) {
this._todayDate = today;
this._todaySyncCount = 0;
}
this._todaySyncCount++;
this._lastSyncAt = new Date();
}
/**
* Sync a shift signup to Listmonk "All Contacts" + "Volunteers" lists.
*/
async onShiftSignup(data: {
email: string;
name: string;
shiftTitle: string;
shiftDate: string;
cutName?: string;
}): Promise<void> {
if (!this.enabled) return;
try {
await listmonkSyncService.ensureInitialized();
const allContactsId = listmonkSyncService.getListId('All Contacts');
const volunteersId = listmonkSyncService.getListId('Volunteers');
if (!allContactsId || !volunteersId) return;
await listmonkClient.upsertSubscriber(
data.email,
data.name,
[allContactsId, volunteersId],
{
source: 'shift_signup',
last_shift_title: data.shiftTitle,
last_shift_date: data.shiftDate,
cut_name: data.cutName || null,
last_synced: new Date().toISOString(),
},
);
this.incrementCounter();
logger.debug(`Listmonk event sync: shift signup for ${data.email}`);
} catch (err) {
logger.debug('Listmonk event sync failed (onShiftSignup):', err);
}
}
/**
* Sync a completed canvass session to Listmonk "All Contacts" + "Canvassers" lists.
*/
async onCanvassSessionCompleted(data: {
email: string;
name: string;
cutName: string;
visitCount: number;
outcomes: Record<string, number>;
}): Promise<void> {
if (!this.enabled) return;
try {
await listmonkSyncService.ensureInitialized();
const allContactsId = listmonkSyncService.getListId('All Contacts');
const canvassersId = listmonkSyncService.getListId('Canvassers');
if (!allContactsId || !canvassersId) return;
await listmonkClient.upsertSubscriber(
data.email,
data.name,
[allContactsId, canvassersId],
{
source: 'canvasser',
last_cut: data.cutName,
last_visit_count: data.visitCount,
last_outcomes: data.outcomes,
last_synced: new Date().toISOString(),
},
);
this.incrementCounter();
logger.debug(`Listmonk event sync: canvass session for ${data.email}`);
} catch (err) {
logger.debug('Listmonk event sync failed (onCanvassSessionCompleted):', err);
}
}
/**
* Sync a sent campaign email to Listmonk "All Contacts" + "Campaign Participants" lists.
*/
async onCampaignEmailSent(data: {
email: string;
name: string;
campaignSlug: string;
postalCode?: string;
}): Promise<void> {
if (!this.enabled) return;
try {
await listmonkSyncService.ensureInitialized();
const allContactsId = listmonkSyncService.getListId('All Contacts');
const participantsId = listmonkSyncService.getListId('Campaign Participants');
if (!allContactsId || !participantsId) return;
await listmonkClient.upsertSubscriber(
data.email,
data.name,
[allContactsId, participantsId],
{
source: 'campaign_participant',
campaign_slug: data.campaignSlug,
postal_code: data.postalCode || null,
last_synced: new Date().toISOString(),
},
);
this.incrementCounter();
logger.debug(`Listmonk event sync: campaign email for ${data.email}`);
} catch (err) {
logger.debug('Listmonk event sync failed (onCampaignEmailSent):', err);
}
}
getStats(): {
enabled: boolean;
lastSyncAt: string | null;
todaySyncCount: number;
} {
return {
enabled: this.enabled,
lastSyncAt: this._lastSyncAt?.toISOString() || null,
todaySyncCount: this._todaySyncCount,
};
}
}
export const listmonkEventSyncService = new ListmonkEventSyncService();

View File

@ -15,6 +15,8 @@ const LIST_DEFINITIONS: Array<{ name: string; tags: string[] }> = [
{ name: 'Support Level 4 (Opposition)', tags: ['v2', 'map', 'support'] },
{ name: 'Has Campaign Sign', tags: ['v2', 'map', 'signs'] },
{ name: 'Users', tags: ['v2', 'users'] },
{ name: 'Volunteers', tags: ['v2', 'map', 'shifts'] },
{ name: 'Canvassers', tags: ['v2', 'map', 'canvass'] },
];
const SUPPORT_LEVEL_LIST_MAP: Record<string, string> = {
@ -49,12 +51,16 @@ class ListmonkSyncService {
logger.info('Listmonk lists initialized', { listIds: this.listIds });
}
private async ensureInitialized(): Promise<void> {
async ensureInitialized(): Promise<void> {
if (!this.initialized) {
await this.initializeLists();
}
}
getListId(name: string): number | undefined {
return this.listIds[name];
}
async syncCampaignParticipants(): Promise<BulkSyncResult> {
await this.ensureInitialized();
const result: BulkSyncResult = { total: 0, success: 0, failed: 0, errors: [] };

View File

@ -0,0 +1,228 @@
import { Queue, Worker, type Job } from 'bullmq';
import { env } from '../config/env';
import { logger } from '../utils/logger';
import { emailService } from './email.service';
// ─── Job Data Types ────────────────────────────────────────────────
interface AdminShiftSignupJob {
type: 'admin-shift-signup';
adminEmails: string[];
shiftTitle: string;
shiftDate: string;
volunteerName: string;
volunteerEmail: string;
signupSource: string;
adminUrl: string;
}
interface AdminResponseSubmittedJob {
type: 'admin-response-submitted';
adminEmails: string[];
campaignTitle: string;
representativeName: string;
responseType: string;
submitterName: string;
adminUrl: string;
}
interface AdminSignRequestedJob {
type: 'admin-sign-requested';
adminEmails: string[];
volunteerName: string;
address: string;
shiftTitle: string;
signSize: string;
adminUrl: string;
}
interface AdminShiftCancellationJob {
type: 'admin-shift-cancellation';
adminEmails: string[];
shiftTitle: string;
shiftDate: string;
volunteerName: string;
volunteerEmail: string;
cancellationSource: string;
adminUrl: string;
}
interface VolunteerSessionSummaryJob {
type: 'volunteer-session-summary';
volunteerEmail: string;
volunteerName: string;
cutName: string;
sessionDate: string;
visitCount: number;
durationMinutes: number;
distanceKm: number;
outcomeBreakdown: Record<string, number>;
}
interface VolunteerCancellationJob {
type: 'volunteer-cancellation';
volunteerEmail: string;
volunteerName: string;
shiftTitle: string;
shiftDate: string;
shiftTime: string;
signupUrl: string;
}
interface VolunteerShiftReminderJob {
type: 'volunteer-shift-reminder';
recipientEmail: string;
recipientName: string;
shiftTitle: string;
shiftDate: string;
shiftStartTime: string;
shiftEndTime: string;
shiftLocation: string;
shiftDescription: string;
currentVolunteers: number;
maxVolunteers: number;
shiftStatus: string;
}
type NotificationJobData =
| AdminShiftSignupJob
| AdminResponseSubmittedJob
| AdminSignRequestedJob
| AdminShiftCancellationJob
| VolunteerSessionSummaryJob
| VolunteerCancellationJob
| VolunteerShiftReminderJob;
// ─── Queue Service ─────────────────────────────────────────────────
class NotificationQueueService {
private queue: Queue;
private worker: Worker | null = null;
constructor() {
this.queue = new Queue('notification-emails', {
connection: { url: env.REDIS_URL },
defaultJobOptions: {
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
removeOnComplete: { age: 24 * 60 * 60, count: 500 },
removeOnFail: { age: 7 * 24 * 60 * 60 },
},
});
}
startWorker() {
this.worker = new Worker(
'notification-emails',
async (job: Job<NotificationJobData>) => {
const { data } = job;
logger.info(`Processing notification job ${job.id} type=${data.type}`);
switch (data.type) {
case 'admin-shift-signup':
await emailService.sendAdminShiftSignupAlert(data);
break;
case 'admin-response-submitted':
await emailService.sendAdminResponseSubmittedAlert(data);
break;
case 'admin-sign-requested':
await emailService.sendAdminSignRequestedAlert(data);
break;
case 'admin-shift-cancellation':
await emailService.sendAdminShiftCancellationAlert(data);
break;
case 'volunteer-session-summary':
await emailService.sendVolunteerSessionSummary(data);
break;
case 'volunteer-cancellation':
await emailService.sendVolunteerCancellationAck(data);
break;
case 'volunteer-shift-reminder':
await emailService.sendShiftDetailsEmail({
recipientEmail: data.recipientEmail,
recipientName: data.recipientName,
shiftTitle: data.shiftTitle,
shiftDate: data.shiftDate,
shiftStartTime: data.shiftStartTime,
shiftEndTime: data.shiftEndTime,
shiftLocation: data.shiftLocation,
shiftDescription: data.shiftDescription,
currentVolunteers: data.currentVolunteers,
maxVolunteers: data.maxVolunteers,
shiftStatus: data.shiftStatus,
});
break;
}
},
{
connection: { url: env.REDIS_URL },
concurrency: 2,
},
);
this.worker.on('completed', (job) => {
logger.info(`Notification job ${job.id} completed`);
});
this.worker.on('failed', (job, err) => {
logger.error(`Notification job ${job?.id} failed: ${err.message}`);
});
logger.info('Notification queue worker started');
}
/** Enqueue an immediate notification job. */
async enqueue(data: NotificationJobData): Promise<string> {
const job = await this.queue.add(data.type, data);
return job.id!;
}
/**
* Schedule a shift reminder as a delayed job.
* Uses a deterministic jobId so we can cancel it later.
*/
async scheduleShiftReminder(
data: VolunteerShiftReminderJob,
shiftDatetime: Date,
): Promise<string | null> {
const reminderTime = new Date(shiftDatetime.getTime() - 24 * 60 * 60 * 1000);
const delay = reminderTime.getTime() - Date.now();
if (delay <= 0) {
logger.debug('Shift is less than 24h away, skipping reminder scheduling');
return null;
}
const jobId = `shift-reminder-${data.recipientEmail}-${shiftDatetime.getTime()}`;
const job = await this.queue.add(data.type, data, {
delay,
jobId,
});
logger.info(`Scheduled shift reminder jobId=${jobId} delay=${Math.round(delay / 60000)}min`);
return job.id!;
}
/** Cancel a pending shift reminder. */
async cancelShiftReminder(email: string, shiftDatetime: Date): Promise<void> {
const jobId = `shift-reminder-${email}-${shiftDatetime.getTime()}`;
try {
const job = await this.queue.getJob(jobId);
if (job) {
await job.remove();
logger.info(`Cancelled shift reminder jobId=${jobId}`);
}
} catch (err) {
logger.warn(`Failed to cancel shift reminder jobId=${jobId}:`, err);
}
}
async close() {
if (this.worker) {
await this.worker.close();
}
await this.queue.close();
logger.info('Notification queue closed');
}
}
export const notificationQueueService = new NotificationQueueService();

View File

@ -0,0 +1,33 @@
import { UserRole, UserStatus } from '@prisma/client';
import { prisma } from '../config/database';
import { siteSettingsService } from '../modules/settings/settings.service';
import { logger } from '../utils/logger';
/**
* Fetch email addresses for active admin users with the specified role(s).
*/
export async function getAdminEmailsByRole(roles: UserRole[]): Promise<string[]> {
const users = await prisma.user.findMany({
where: {
role: { in: roles },
status: UserStatus.ACTIVE,
},
select: { email: true },
});
return users.map((u) => u.email);
}
/**
* Check whether a specific notification toggle is enabled in SiteSettings.
* Fail-open: returns true if the field is missing or settings query fails.
*/
export async function isNotificationEnabled(key: string): Promise<boolean> {
try {
const settings = await siteSettingsService.get();
const value = (settings as Record<string, unknown>)[key];
return value !== false;
} catch (err) {
logger.warn(`Failed to read notification setting "${key}", defaulting to enabled:`, err);
return true;
}
}

View File

@ -0,0 +1,78 @@
import { env } from '../config/env';
import { logger } from '../utils/logger';
import { rocketchatClient } from './rocketchat.client';
class RocketChatWebhookService {
private get enabled(): boolean {
return env.ENABLE_CHAT === 'true';
}
/**
* Post a notification to a Rocket.Chat channel.
* Silently fails if chat is disabled or RC is unreachable.
*/
private async notify(channel: string, text: string, color?: string): Promise<void> {
if (!this.enabled) return;
try {
await rocketchatClient.postMessage(channel, text, 'Changemaker Bot');
} catch (err) {
logger.debug(`RC notification to ${channel} failed (non-critical):`, err);
}
}
// --- Event Formatters ---
async onShiftSignup(data: {
userName: string;
shiftTitle: string;
shiftDate: string;
}): Promise<void> {
const text = `:calendar: **${data.userName}** signed up for shift: *${data.shiftTitle}* (${data.shiftDate})`;
await this.notify('#shifts', text, '#27ae60');
}
async onShiftCancellation(data: {
userName: string;
shiftTitle: string;
shiftDate: string;
}): Promise<void> {
const text = `:x: **${data.userName}** cancelled signup for shift: *${data.shiftTitle}* (${data.shiftDate})`;
await this.notify('#shifts', text, '#e74c3c');
}
async onCanvassSessionCompleted(data: {
userName: string;
visitCount: number;
cutName?: string;
}): Promise<void> {
const location = data.cutName ? ` in ${data.cutName}` : '';
const text = `:door: **${data.userName}** completed ${data.visitCount} visits${location}`;
await this.notify('#canvassing', text, '#3498db');
}
async onCampaignResponseSubmitted(data: {
campaignTitle: string;
representativeName: string;
}): Promise<void> {
const text = `:mega: New response submitted for campaign *${data.campaignTitle}* from **${data.representativeName}**`;
await this.notify('#campaigns', text, '#9b59b6');
}
/**
* Ensure default notification channels exist in Rocket.Chat.
* Called during service startup.
*/
async setupChannels(): Promise<void> {
if (!this.enabled) return;
try {
await rocketchatClient.ensureChannel('shifts', 'Shift coordination and updates');
await rocketchatClient.ensureChannel('canvassing', 'Canvass activity and updates');
await rocketchatClient.ensureChannel('campaigns', 'Campaign activity and responses');
logger.info('RC notification channels verified');
} catch (err) {
logger.warn('RC channel setup failed (will retry on next notification):', err);
}
}
}
export const rocketchatWebhookService = new RocketChatWebhookService();

View File

@ -0,0 +1,331 @@
import { env } from '../config/env';
import { logger } from '../utils/logger';
// --- Types ---
export interface RCUser {
_id: string;
username: string;
name: string;
emails: { address: string; verified: boolean }[];
roles: string[];
active: boolean;
}
export interface RCLoginResponse {
status: string;
data: {
userId: string;
authToken: string;
me: RCUser;
};
}
export interface RCUserCreateResponse {
user: RCUser;
success: boolean;
}
export interface RCUserTokenResponse {
data: {
userId: string;
authToken: string;
};
success: boolean;
}
export interface RCInfoResponse {
info: {
version: string;
};
success: boolean;
}
export interface RCChannelCreateResponse {
channel: {
_id: string;
name: string;
};
success: boolean;
}
// --- Client ---
class RocketChatClient {
private adminToken: string | null = null;
private adminUserId: string | null = null;
private tokenExpiresAt = 0;
private get baseUrl(): string {
return env.ROCKETCHAT_URL;
}
private get hasCredentials(): boolean {
return !!env.ROCKETCHAT_ADMIN_USER &&
!!env.ROCKETCHAT_ADMIN_PASSWORD;
}
/**
* Make an authenticated request to Rocket.Chat REST API
*/
private async request<T>(
method: string,
path: string,
body?: unknown,
skipAuth = false,
): Promise<T> {
const url = `${this.baseUrl}/api/v1${path}`;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (!skipAuth) {
await this.ensureAdminAuth();
if (this.adminToken && this.adminUserId) {
headers['X-Auth-Token'] = this.adminToken;
headers['X-User-Id'] = this.adminUserId;
}
}
const res = await fetch(url, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
const data = await res.json() as T & { success?: boolean; error?: string };
if (!res.ok || data.success === false) {
const msg = data.error || `RC API error ${res.status}`;
throw new Error(msg);
}
return data;
}
// --- Auth ---
/**
* Authenticate as admin, cache token for 1 hour
*/
async ensureAdminAuth(): Promise<void> {
if (this.adminToken && Date.now() < this.tokenExpiresAt) return;
const url = `${this.baseUrl}/api/v1/login`;
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user: env.ROCKETCHAT_ADMIN_USER,
password: env.ROCKETCHAT_ADMIN_PASSWORD,
}),
});
const data = await res.json() as RCLoginResponse;
if (!res.ok || data.status !== 'success') {
throw new Error('Failed to authenticate with Rocket.Chat admin');
}
this.adminToken = data.data.authToken;
this.adminUserId = data.data.userId;
// Cache for 1 hour
this.tokenExpiresAt = Date.now() + 60 * 60 * 1000;
logger.debug('Rocket.Chat admin auth refreshed');
}
// --- Health ---
async healthCheck(): Promise<boolean> {
if (!this.hasCredentials) return false;
try {
const url = `${this.baseUrl}/api/info`;
const res = await fetch(url, { method: 'GET', signal: AbortSignal.timeout(5000) });
return res.ok;
} catch {
return false;
}
}
// --- Channel History ---
/**
* Get recent messages from a channel by name
*/
async getChannelHistory(channelName: string, count = 5): Promise<Array<{
_id: string;
msg: string;
u: { username: string };
ts: string;
bot?: { i: string };
alias?: string;
}>> {
try {
const data = await this.request<{
messages: Array<{
_id: string;
msg: string;
u: { username: string };
ts: string;
bot?: { i: string };
alias?: string;
}>;
success: boolean;
}>('GET', `/channels.history?roomName=${encodeURIComponent(channelName)}&count=${count}`);
return data.messages || [];
} catch {
return [];
}
}
// --- User Management ---
/**
* Find a Rocket.Chat user by email address
*/
async findUserByEmail(email: string): Promise<RCUser | null> {
try {
const data = await this.request<{ users: RCUser[]; success: boolean }>(
'GET',
`/users.list?query=${encodeURIComponent(email)}&count=1`,
);
const match = data.users?.find(u =>
u.emails?.some(e => e.address.toLowerCase() === email.toLowerCase()),
);
return match || null;
} catch (err) {
logger.warn('RC findUserByEmail failed:', err);
return null;
}
}
/**
* Create a new Rocket.Chat user
*/
async createUser(data: {
email: string;
name: string;
username: string;
password: string;
roles?: string[];
}): Promise<RCUser> {
const res = await this.request<RCUserCreateResponse>('POST', '/users.create', {
email: data.email,
name: data.name,
username: data.username,
password: data.password,
roles: data.roles || ['user'],
verified: true,
joinDefaultChannels: true,
requirePasswordChange: false,
sendWelcomeEmail: false,
});
return res.user;
}
/**
* Update an existing Rocket.Chat user's roles and name
*/
async updateUser(userId: string, data: { name?: string; roles?: string[] }): Promise<void> {
await this.request('POST', '/users.update', {
userId,
data,
});
}
/**
* Set user active/inactive status
*/
async setUserActive(userId: string, active: boolean): Promise<void> {
await this.request('POST', '/users.setActiveStatus', {
userId,
activeStatus: active,
});
}
/**
* Generate a one-time login token for a user (SSO)
* Requires CREATE_TOKENS_FOR_USERS=true on the RC instance
*/
async createUserToken(userId: string): Promise<{ authToken: string; userId: string }> {
const res = await this.request<RCUserTokenResponse>('POST', '/users.createToken', {
userId,
});
return res.data;
}
// --- Channels ---
/**
* Create a channel (idempotent returns existing if name taken)
*/
async ensureChannel(name: string, topic?: string): Promise<string> {
try {
const res = await this.request<RCChannelCreateResponse>('POST', '/channels.create', {
name,
readOnly: false,
});
if (topic) {
await this.request('POST', '/channels.setTopic', {
roomId: res.channel._id,
topic,
});
}
return res.channel._id;
} catch (err) {
// Channel already exists — look it up
if (err instanceof Error && (err.message.includes('already in use') || err.message.includes('duplicate-channel-name') || err.message.includes('exists'))) {
const info = await this.request<{ channel: { _id: string }; success: boolean }>(
'GET',
`/channels.info?roomName=${encodeURIComponent(name)}`,
);
return info.channel._id;
}
throw err;
}
}
// --- Webhooks ---
/**
* Post a message to an incoming webhook URL
*/
async postWebhook(webhookUrl: string, payload: {
text?: string;
channel?: string;
alias?: string;
emoji?: string;
attachments?: Array<{
title?: string;
text?: string;
color?: string;
fields?: Array<{ title: string; value: string; short?: boolean }>;
}>;
}): Promise<void> {
try {
await fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
} catch (err) {
logger.warn('RC webhook post failed:', err);
}
}
/**
* Post a message to a channel using the REST API (no webhook needed)
*/
async postMessage(channel: string, text: string, alias?: string): Promise<void> {
try {
await this.request('POST', '/chat.postMessage', {
channel,
text,
alias: alias || 'Changemaker Bot',
});
} catch (err) {
logger.warn(`RC postMessage to ${channel} failed:`, err);
}
}
}
export const rocketchatClient = new RocketChatClient();

View File

@ -0,0 +1,112 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>New Response Submitted — {{ORGANIZATION_NAME}}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.container {
background-color: #f9f9f9;
border-radius: 8px;
padding: 30px;
border: 1px solid #e0e0e0;
}
.header {
text-align: center;
margin-bottom: 30px;
border-bottom: 2px solid #d97706;
padding-bottom: 20px;
}
.logo {
color: #d97706;
font-size: 24px;
font-weight: bold;
}
.content {
background-color: white;
padding: 25px;
border-radius: 6px;
margin-bottom: 20px;
border-left: 4px solid #d97706;
}
.info-section {
background-color: #fffbeb;
padding: 15px;
border-radius: 4px;
margin: 16px 0;
border: 1px solid #fde68a;
}
.info-item {
margin: 6px 0;
}
.info-label {
font-weight: bold;
color: #92400e;
}
.btn {
display: inline-block;
padding: 14px 32px;
border-radius: 6px;
font-size: 16px;
font-weight: bold;
text-decoration: none;
background: linear-gradient(135deg, #d97706, #b45309);
color: white;
}
.btn-container {
text-align: center;
margin: 24px 0;
}
.footer {
text-align: center;
font-size: 12px;
color: #6c757d;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e9ecef;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">{{ORGANIZATION_NAME}}</div>
<p style="margin: 10px 0 0 0; color: #6c757d;">New Response Submitted</p>
</div>
<div class="content">
<p>A new response has been submitted to the response wall and is awaiting moderation:</p>
<div class="info-section">
<div class="info-item">
<span class="info-label">Campaign:</span> {{CAMPAIGN_TITLE}}
</div>
<div class="info-item">
<span class="info-label">Representative:</span> {{REPRESENTATIVE_NAME}}
</div>
<div class="info-item">
<span class="info-label">Response Type:</span> {{RESPONSE_TYPE}}
</div>
<div class="info-item">
<span class="info-label">Submitted By:</span> {{SUBMITTER_NAME}}
</div>
</div>
<div class="btn-container">
<a href="{{ADMIN_URL}}" class="btn">Review Responses</a>
</div>
</div>
<div class="footer">
<p>This is an admin notification from <strong>{{ORGANIZATION_NAME}}</strong></p>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,12 @@
{{ORGANIZATION_NAME}} — New Response Submitted
A new response has been submitted to the response wall and is awaiting moderation:
Campaign: {{CAMPAIGN_TITLE}}
Representative: {{REPRESENTATIVE_NAME}}
Response Type: {{RESPONSE_TYPE}}
Submitted By: {{SUBMITTER_NAME}}
Review responses: {{ADMIN_URL}}
— {{ORGANIZATION_NAME}}

View File

@ -0,0 +1,115 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Shift Cancellation — {{ORGANIZATION_NAME}}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.container {
background-color: #f9f9f9;
border-radius: 8px;
padding: 30px;
border: 1px solid #e0e0e0;
}
.header {
text-align: center;
margin-bottom: 30px;
border-bottom: 2px solid #dc3545;
padding-bottom: 20px;
}
.logo {
color: #dc3545;
font-size: 24px;
font-weight: bold;
}
.content {
background-color: white;
padding: 25px;
border-radius: 6px;
margin-bottom: 20px;
border-left: 4px solid #dc3545;
}
.info-section {
background-color: #fff5f5;
padding: 15px;
border-radius: 4px;
margin: 16px 0;
border: 1px solid #fed7d7;
}
.info-item {
margin: 6px 0;
}
.info-label {
font-weight: bold;
color: #9b2c2c;
}
.btn {
display: inline-block;
padding: 14px 32px;
border-radius: 6px;
font-size: 16px;
font-weight: bold;
text-decoration: none;
background: linear-gradient(135deg, #dc3545, #b02a37);
color: white;
}
.btn-container {
text-align: center;
margin: 24px 0;
}
.footer {
text-align: center;
font-size: 12px;
color: #6c757d;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e9ecef;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">{{ORGANIZATION_NAME}}</div>
<p style="margin: 10px 0 0 0; color: #6c757d;">Shift Cancellation</p>
</div>
<div class="content">
<p>A volunteer has cancelled their shift signup:</p>
<div class="info-section">
<div class="info-item">
<span class="info-label">Shift:</span> {{SHIFT_TITLE}}
</div>
<div class="info-item">
<span class="info-label">Date:</span> {{SHIFT_DATE}}
</div>
<div class="info-item">
<span class="info-label">Volunteer:</span> {{VOLUNTEER_NAME}}
</div>
<div class="info-item">
<span class="info-label">Email:</span> {{VOLUNTEER_EMAIL}}
</div>
<div class="info-item">
<span class="info-label">Source:</span> {{CANCELLATION_SOURCE}}
</div>
</div>
<div class="btn-container">
<a href="{{ADMIN_URL}}" class="btn">View in Admin</a>
</div>
</div>
<div class="footer">
<p>This is an admin notification from <strong>{{ORGANIZATION_NAME}}</strong></p>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,13 @@
{{ORGANIZATION_NAME}} — Shift Cancellation
A volunteer has cancelled their shift signup:
Shift: {{SHIFT_TITLE}}
Date: {{SHIFT_DATE}}
Volunteer: {{VOLUNTEER_NAME}}
Email: {{VOLUNTEER_EMAIL}}
Source: {{CANCELLATION_SOURCE}}
View in admin: {{ADMIN_URL}}
— {{ORGANIZATION_NAME}}

View File

@ -0,0 +1,115 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>New Shift Signup — {{ORGANIZATION_NAME}}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.container {
background-color: #f9f9f9;
border-radius: 8px;
padding: 30px;
border: 1px solid #e0e0e0;
}
.header {
text-align: center;
margin-bottom: 30px;
border-bottom: 2px solid #d97706;
padding-bottom: 20px;
}
.logo {
color: #d97706;
font-size: 24px;
font-weight: bold;
}
.content {
background-color: white;
padding: 25px;
border-radius: 6px;
margin-bottom: 20px;
border-left: 4px solid #d97706;
}
.info-section {
background-color: #fffbeb;
padding: 15px;
border-radius: 4px;
margin: 16px 0;
border: 1px solid #fde68a;
}
.info-item {
margin: 6px 0;
}
.info-label {
font-weight: bold;
color: #92400e;
}
.btn {
display: inline-block;
padding: 14px 32px;
border-radius: 6px;
font-size: 16px;
font-weight: bold;
text-decoration: none;
background: linear-gradient(135deg, #d97706, #b45309);
color: white;
}
.btn-container {
text-align: center;
margin: 24px 0;
}
.footer {
text-align: center;
font-size: 12px;
color: #6c757d;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e9ecef;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">{{ORGANIZATION_NAME}}</div>
<p style="margin: 10px 0 0 0; color: #6c757d;">New Shift Signup</p>
</div>
<div class="content">
<p>A volunteer has signed up for a shift:</p>
<div class="info-section">
<div class="info-item">
<span class="info-label">Shift:</span> {{SHIFT_TITLE}}
</div>
<div class="info-item">
<span class="info-label">Date:</span> {{SHIFT_DATE}}
</div>
<div class="info-item">
<span class="info-label">Volunteer:</span> {{VOLUNTEER_NAME}}
</div>
<div class="info-item">
<span class="info-label">Email:</span> {{VOLUNTEER_EMAIL}}
</div>
<div class="info-item">
<span class="info-label">Source:</span> {{SIGNUP_SOURCE}}
</div>
</div>
<div class="btn-container">
<a href="{{ADMIN_URL}}" class="btn">View in Admin</a>
</div>
</div>
<div class="footer">
<p>This is an admin notification from <strong>{{ORGANIZATION_NAME}}</strong></p>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,13 @@
{{ORGANIZATION_NAME}} — New Shift Signup
A volunteer has signed up for a shift:
Shift: {{SHIFT_TITLE}}
Date: {{SHIFT_DATE}}
Volunteer: {{VOLUNTEER_NAME}}
Email: {{VOLUNTEER_EMAIL}}
Source: {{SIGNUP_SOURCE}}
View in admin: {{ADMIN_URL}}
— {{ORGANIZATION_NAME}}

View File

@ -0,0 +1,112 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Sign Requested — {{ORGANIZATION_NAME}}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.container {
background-color: #f9f9f9;
border-radius: 8px;
padding: 30px;
border: 1px solid #e0e0e0;
}
.header {
text-align: center;
margin-bottom: 30px;
border-bottom: 2px solid #dc2626;
padding-bottom: 20px;
}
.logo {
color: #dc2626;
font-size: 24px;
font-weight: bold;
}
.content {
background-color: white;
padding: 25px;
border-radius: 6px;
margin-bottom: 20px;
border-left: 4px solid #dc2626;
}
.info-section {
background-color: #fef2f2;
padding: 15px;
border-radius: 4px;
margin: 16px 0;
border: 1px solid #fecaca;
}
.info-item {
margin: 6px 0;
}
.info-label {
font-weight: bold;
color: #991b1b;
}
.btn {
display: inline-block;
padding: 14px 32px;
border-radius: 6px;
font-size: 16px;
font-weight: bold;
text-decoration: none;
background: linear-gradient(135deg, #dc2626, #b91c1c);
color: white;
}
.btn-container {
text-align: center;
margin: 24px 0;
}
.footer {
text-align: center;
font-size: 12px;
color: #6c757d;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e9ecef;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">{{ORGANIZATION_NAME}}</div>
<p style="margin: 10px 0 0 0; color: #6c757d;">Sign Requested</p>
</div>
<div class="content">
<p>A resident has requested a yard sign during canvassing:</p>
<div class="info-section">
<div class="info-item">
<span class="info-label">Address:</span> {{ADDRESS}}
</div>
<div class="info-item">
<span class="info-label">Sign Size:</span> {{SIGN_SIZE}}
</div>
<div class="info-item">
<span class="info-label">Canvasser:</span> {{VOLUNTEER_NAME}}
</div>
<div class="info-item">
<span class="info-label">Shift:</span> {{SHIFT_TITLE}}
</div>
</div>
<div class="btn-container">
<a href="{{ADMIN_URL}}" class="btn">View in Admin</a>
</div>
</div>
<div class="footer">
<p>This is an admin notification from <strong>{{ORGANIZATION_NAME}}</strong></p>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,12 @@
{{ORGANIZATION_NAME}} — Sign Requested
A resident has requested a yard sign during canvassing:
Address: {{ADDRESS}}
Sign Size: {{SIGN_SIZE}}
Canvasser: {{VOLUNTEER_NAME}}
Shift: {{SHIFT_TITLE}}
View in admin: {{ADMIN_URL}}
— {{ORGANIZATION_NAME}}

View File

@ -0,0 +1,112 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Signup Cancelled — {{ORGANIZATION_NAME}}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.container {
background-color: #f9f9f9;
border-radius: 8px;
padding: 30px;
border: 1px solid #e0e0e0;
}
.header {
text-align: center;
margin-bottom: 30px;
border-bottom: 2px solid #6b7280;
padding-bottom: 20px;
}
.logo {
color: #6b7280;
font-size: 24px;
font-weight: bold;
}
.content {
background-color: white;
padding: 25px;
border-radius: 6px;
margin-bottom: 20px;
border-left: 4px solid #6b7280;
}
.info-section {
background-color: #f9fafb;
padding: 15px;
border-radius: 4px;
margin: 16px 0;
border: 1px solid #e5e7eb;
}
.info-item {
margin: 6px 0;
}
.info-label {
font-weight: bold;
color: #374151;
}
.btn {
display: inline-block;
padding: 14px 32px;
border-radius: 6px;
font-size: 16px;
font-weight: bold;
text-decoration: none;
background: linear-gradient(135deg, #2563eb, #1d4ed8);
color: white;
}
.btn-container {
text-align: center;
margin: 24px 0;
}
.footer {
text-align: center;
font-size: 12px;
color: #6c757d;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e9ecef;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">{{ORGANIZATION_NAME}}</div>
<p style="margin: 10px 0 0 0; color: #6c757d;">Signup Cancelled</p>
</div>
<div class="content">
<p>Hi {{VOLUNTEER_NAME}},</p>
<p>Your shift signup has been cancelled. Here are the details:</p>
<div class="info-section">
<div class="info-item">
<span class="info-label">Shift:</span> {{SHIFT_TITLE}}
</div>
<div class="info-item">
<span class="info-label">Date:</span> {{SHIFT_DATE}}
</div>
<div class="info-item">
<span class="info-label">Time:</span> {{SHIFT_TIME}}
</div>
</div>
<p>If this was a mistake, you can sign up again:</p>
<div class="btn-container">
<a href="{{SIGNUP_URL}}" class="btn">View Available Shifts</a>
</div>
</div>
<div class="footer">
<p>This email was sent by <strong>{{ORGANIZATION_NAME}}</strong></p>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,13 @@
{{ORGANIZATION_NAME}} — Signup Cancelled
Hi {{VOLUNTEER_NAME}},
Your shift signup has been cancelled:
Shift: {{SHIFT_TITLE}}
Date: {{SHIFT_DATE}}
Time: {{SHIFT_TIME}}
If this was a mistake, you can sign up again: {{SIGNUP_URL}}
— {{ORGANIZATION_NAME}}

View File

@ -0,0 +1,145 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Canvass Session Summary — {{ORGANIZATION_NAME}}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.container {
background-color: #f9f9f9;
border-radius: 8px;
padding: 30px;
border: 1px solid #e0e0e0;
}
.header {
text-align: center;
margin-bottom: 30px;
border-bottom: 2px solid #2563eb;
padding-bottom: 20px;
}
.logo {
color: #2563eb;
font-size: 24px;
font-weight: bold;
}
.content {
background-color: white;
padding: 25px;
border-radius: 6px;
margin-bottom: 20px;
border-left: 4px solid #2563eb;
}
.info-section {
background-color: #eff6ff;
padding: 15px;
border-radius: 4px;
margin: 16px 0;
border: 1px solid #bfdbfe;
}
.info-item {
margin: 6px 0;
}
.info-label {
font-weight: bold;
color: #1e40af;
}
.stats-grid {
display: flex;
gap: 12px;
flex-wrap: wrap;
margin: 16px 0;
}
.stat-box {
flex: 1;
min-width: 100px;
background-color: #f0f9ff;
border: 1px solid #bae6fd;
border-radius: 6px;
padding: 12px;
text-align: center;
}
.stat-number {
font-size: 24px;
font-weight: bold;
color: #2563eb;
}
.stat-label {
font-size: 12px;
color: #64748b;
margin-top: 4px;
}
.outcome-table {
width: 100%;
border-collapse: collapse;
margin: 16px 0;
}
.outcome-table th, .outcome-table td {
padding: 8px 12px;
text-align: left;
border-bottom: 1px solid #e2e8f0;
}
.outcome-table th {
background-color: #f8fafc;
font-weight: bold;
color: #475569;
}
.footer {
text-align: center;
font-size: 12px;
color: #6c757d;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e9ecef;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">{{ORGANIZATION_NAME}}</div>
<p style="margin: 10px 0 0 0; color: #6c757d;">Canvass Session Summary</p>
</div>
<div class="content">
<p>Great work, {{VOLUNTEER_NAME}}! Here's a summary of your canvassing session:</p>
<div class="info-section">
<div class="info-item">
<span class="info-label">Area:</span> {{CUT_NAME}}
</div>
<div class="info-item">
<span class="info-label">Date:</span> {{SESSION_DATE}}
</div>
</div>
<div class="stats-grid">
<div class="stat-box">
<div class="stat-number">{{VISIT_COUNT}}</div>
<div class="stat-label">Doors Visited</div>
</div>
<div class="stat-box">
<div class="stat-number">{{DURATION_MINUTES}}</div>
<div class="stat-label">Minutes</div>
</div>
<div class="stat-box">
<div class="stat-number">{{DISTANCE_KM}}</div>
<div class="stat-label">km Walked</div>
</div>
</div>
{{OUTCOME_BREAKDOWN}}
</div>
<div class="footer">
<p>Thank you for volunteering with <strong>{{ORGANIZATION_NAME}}</strong></p>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,13 @@
{{ORGANIZATION_NAME}} — Canvass Session Summary
Great work, {{VOLUNTEER_NAME}}! Here's a summary of your canvassing session:
Area: {{CUT_NAME}}
Date: {{SESSION_DATE}}
Doors Visited: {{VISIT_COUNT}}
Duration: {{DURATION_MINUTES}} minutes
Distance: {{DISTANCE_KM}} km
{{OUTCOME_BREAKDOWN}}
Thank you for volunteering with {{ORGANIZATION_NAME}}!

View File

@ -1,8 +1,12 @@
import client from 'prom-client';
import { env } from '../config/env';
const register = new client.Registry();
register.setDefaultLabels({ app: 'changemaker-v2-api' });
register.setDefaultLabels({
app: 'changemaker-v2-api',
instance: env.INSTANCE_LABEL || env.DOMAIN || 'unknown',
});
client.collectDefaultMetrics({ register });

13
bunker-ops/.gitignore vendored Normal file
View File

@ -0,0 +1,13 @@
# Vault password file (NEVER commit)
.vault_pass
# Encrypted vault files should be committed, but plaintext should not
# (bootstrap-vault.sh encrypts automatically if .vault_pass exists)
# Ansible retry files
*.retry
# SSH keys
*.pem
*.key
id_rsa*

519
bunker-ops/HOWTO.md Normal file
View File

@ -0,0 +1,519 @@
# Bunker Ops — How-To Guide
Operational handbook for managing Changemaker Lite instances with Ansible.
---
## Table of Contents
1. [Prerequisites](#1-prerequisites)
2. [Initial Setup (Control Machine)](#2-initial-setup-control-machine)
3. [Adding a New Instance](#3-adding-a-new-instance)
4. [Deploying an Instance](#4-deploying-an-instance)
5. [Day-to-Day Operations](#5-day-to-day-operations)
6. [Secret Management](#6-secret-management)
7. [Monitoring & Fleet Observability](#7-monitoring--fleet-observability)
8. [Troubleshooting](#8-troubleshooting)
9. [Variable Reference](#9-variable-reference)
---
## 1. Prerequisites
### Control Machine (your laptop / jump server)
- **Ansible 2.14+**`pip install ansible` or `apt install ansible`
- **SSH access** — key-based auth to all target servers
- **OpenSSL** — for secret generation (`openssl rand`)
### Target Servers (each Changemaker instance)
- **Ubuntu 22.04 or 24.04** (Debian-based)
- **2+ GB RAM** (4 GB recommended; swap is auto-created on low-memory hosts)
- **20+ GB disk** (50 GB recommended for media features)
- **SSH access** for a `deploy` user with passwordless sudo
- **Outbound internet** (pulls Docker images, Git repo)
- Ports 80, 443, and SSH accessible
---
## 2. Initial Setup (Control Machine)
### 2.1 Clone the repository
```bash
git clone <repo-url> changemaker.lite
cd changemaker.lite/bunker-ops
```
### 2.2 Create a vault password
This single password encrypts all per-instance secrets. Store it securely (password manager, not Git).
```bash
# Generate a strong vault password
openssl rand -base64 32 > .vault_pass
chmod 600 .vault_pass
```
The `.vault_pass` file is in `.gitignore` and must never be committed.
### 2.3 Verify Ansible can run
```bash
ansible --version
ansible-playbook playbooks/deploy.yml --syntax-check
```
### 2.4 Prepare SSH access
Ensure your SSH key can reach target servers:
```bash
# Test connectivity
ssh deploy@10.0.1.10 "hostname && docker --version"
```
If you use a non-default SSH key:
```bash
# In ansible.cfg or per-host
ansible_ssh_private_key_file: ~/.ssh/bunker_ops_ed25519
```
---
## 3. Adding a New Instance
### 3.1 Quick method (recommended)
The `add-instance.sh` script scaffolds everything:
```bash
./scripts/add-instance.sh edmonton-prod betteredmonton.org 10.0.1.10
# With fleet observability (Tier 2):
./scripts/add-instance.sh edmonton-prod betteredmonton.org 10.0.1.10 --tier 2
```
This creates:
- `inventory/host_vars/edmonton-prod/main.yml` — instance configuration
- `inventory/host_vars/edmonton-prod/vault.yml` — 19+ generated secrets (encrypted)
### 3.2 Add to inventory
Edit `inventory/hosts.yml` and add the host:
```yaml
all:
children:
changemaker_instances:
hosts:
edmonton-prod:
ansible_host: 10.0.1.10
ansible_user: deploy
cml_domain: betteredmonton.org
```
### 3.3 Customize configuration
Edit `inventory/host_vars/edmonton-prod/main.yml`:
```yaml
cml_domain: betteredmonton.org
cml_node_env: production
# Enable features
cml_enable_media: "true"
cml_listmonk_sync_enabled: "true"
cml_email_test_mode: "false"
cml_monitoring_enabled: true
# Production SMTP
cml_smtp_host: smtp.protonmail.ch
cml_smtp_port: 587
cml_smtp_user: "noreply@betteredmonton.org"
# Pangolin tunnel
cml_pangolin_api_url: "https://api.bnkserve.org/v1"
cml_pangolin_org_id: "org_abc123"
```
### 3.4 Edit secrets (if needed)
```bash
# Decrypt, edit, re-encrypt
ansible-vault edit inventory/host_vars/edmonton-prod/vault.yml
# Or set a specific value
ansible-vault decrypt inventory/host_vars/edmonton-prod/vault.yml
# ... edit ...
ansible-vault encrypt inventory/host_vars/edmonton-prod/vault.yml
```
### 3.5 Verify connectivity
```bash
ansible edmonton-prod -m ping
```
---
## 4. Deploying an Instance
### 4.1 Full initial deploy
Installs Docker, configures the OS, clones the repo, generates `.env`, starts all containers, runs migrations, and sets up backup cron:
```bash
ansible-playbook playbooks/deploy.yml --limit edmonton-prod
```
What happens (in order):
1. **common** role — apt update, Docker install, UFW firewall, fail2ban, swap
2. **changemaker** role — git clone, create dirs, generate `.env`, `docker compose up`, Prisma migrations, seed, health checks, backup cron
3. **monitoring** role (if enabled) — Prometheus config, `--profile monitoring up`
### 4.2 Deploy all instances
```bash
# One at a time (safe):
ansible-playbook playbooks/deploy.yml
# Show what would change (dry run):
ansible-playbook playbooks/deploy.yml --check --diff
```
### 4.3 Deploy with specific tags
```bash
# Only regenerate .env (no Docker restart):
ansible-playbook playbooks/deploy.yml --limit edmonton-prod --tags env
# Only clone + update code:
ansible-playbook playbooks/deploy.yml --limit edmonton-prod --tags clone
# Only run health checks:
ansible-playbook playbooks/deploy.yml --limit edmonton-prod --tags health
```
---
## 5. Day-to-Day Operations
### 5.1 Rolling upgrade (code + images)
Pulls latest Git commits, rebuilds images, runs migrations, restarts — in 25% batches:
```bash
# All instances:
ansible-playbook playbooks/upgrade.yml
# Single instance:
ansible-playbook playbooks/upgrade.yml --limit edmonton-prod
```
### 5.2 Configuration change (no rebuild)
Regenerates `.env` and restarts the API. Use when changing feature flags, SMTP settings, CORS origins, etc.:
```bash
# Change a variable first:
# Edit inventory/host_vars/edmonton-prod/main.yml
# e.g., cml_enable_media: "true"
# Then apply:
ansible-playbook playbooks/configure.yml --limit edmonton-prod
```
### 5.3 Trigger backups
```bash
# All instances:
ansible-playbook playbooks/backup.yml
# Single instance:
ansible-playbook playbooks/backup.yml --limit edmonton-prod
```
### 5.4 Enable/reconfigure monitoring
```bash
ansible-playbook playbooks/monitoring.yml --limit edmonton-prod
```
### 5.5 Run ad-hoc commands
```bash
# Check Docker status on all instances:
ansible changemaker_instances -m command -a "docker compose ps" --become
# View API logs on one instance:
ansible edmonton-prod -m command -a "docker compose logs api --tail 50" \
--become -e "chdir=/opt/changemaker-lite"
# Restart a specific service:
ansible edmonton-prod -m command -a "docker compose restart api" \
--become -e "chdir=/opt/changemaker-lite"
# Check disk space across fleet:
ansible changemaker_instances -m command -a "df -h /"
```
### 5.6 Rotate a secret
1. Generate a new value:
```bash
openssl rand -hex 32
```
2. Update the vault:
```bash
ansible-vault edit inventory/host_vars/edmonton-prod/vault.yml
# Change vault_cml_jwt_access_secret (or whichever secret)
```
3. Apply and restart:
```bash
ansible-playbook playbooks/configure.yml --limit edmonton-prod
```
---
## 6. Secret Management
### Naming convention
| Prefix | Purpose | Example |
|--------|---------|---------|
| `cml_*` | Non-secret configuration | `cml_domain`, `cml_smtp_host` |
| `vault_cml_*` | Encrypted secrets | `vault_cml_v2_postgres_password` |
| `vault_bunker_*` | Bunker Ops shared secrets | `vault_bunker_ops_remote_write_token` |
### What gets encrypted
All 19+ secrets per instance:
- Database passwords (PostgreSQL, Redis, Listmonk DB, Gitea DB)
- JWT secrets (access + refresh) and encryption key
- Admin passwords (initial admin, NocoDB, n8n, Grafana, Gotify, Vaultwarden, Rocket.Chat, Gancio)
- API tokens (Listmonk API, Pangolin, Bunker Ops remote write)
- SMTP password
### Vault operations
```bash
# View encrypted file:
ansible-vault view inventory/host_vars/edmonton-prod/vault.yml
# Edit in-place (decrypts → opens $EDITOR → re-encrypts):
ansible-vault edit inventory/host_vars/edmonton-prod/vault.yml
# Re-key all vaults (change master password):
find inventory/host_vars -name vault.yml -exec ansible-vault rekey {} +
# Encrypt a new plaintext file:
ansible-vault encrypt inventory/host_vars/new-instance/vault.yml
```
### Vault password management
- The `.vault_pass` file is referenced in `ansible.cfg`
- For CI/CD, pass via environment: `ANSIBLE_VAULT_PASSWORD=... ansible-playbook ...`
- For teams, use `--vault-password-file` pointing to a shared secrets manager script
---
## 7. Monitoring & Fleet Observability
### Tier model
| Tier | What it means | How to set |
|------|--------------|-----------|
| **0: Standalone** | No Ansible management (manual `config.sh` install) | N/A |
| **1: Managed** | Ansible deploys/updates, local monitoring only | `bunker_ops_enabled: false` |
| **2: Fleet** | Ansible + metrics pushed to central VictoriaMetrics | `bunker_ops_enabled: true` |
### Enabling Tier 2 on an instance
1. Set in `host_vars/<hostname>/main.yml`:
```yaml
bunker_ops_enabled: true
bunker_ops_remote_write_url: "https://ops.bnkserve.org/api/v1/write"
cml_monitoring_enabled: true
```
2. Set the write token in `host_vars/<hostname>/vault.yml`:
```yaml
vault_bunker_ops_remote_write_token: "your-token-here"
```
3. Apply:
```bash
ansible-playbook playbooks/monitoring.yml --limit edmonton-prod
```
### What metrics are sent (Tier 2)
Only filtered, non-PII metrics leave the instance:
- `cm_*` — Application metrics (emails sent, canvass visits, queue sizes, login attempts)
- `node_*` — System metrics (CPU, memory, disk, network)
- `http_request*` — API latency and request counts
- `up` — Service availability
**Never sent:** Database content, user data, campaign text, participant records, cAdvisor container details.
### Backup metrics
When `BUNKER_OPS_ENABLED=true`, the backup script automatically pushes:
- `cm_backup_last_success_timestamp` — Unix timestamp of last successful backup
- `cm_backup_size_bytes` — Size of the backup archive
These enable "backup staleness" alerts on the central dashboard.
---
## 8. Troubleshooting
### Ansible can't connect
```
UNREACHABLE! => {"msg": "Failed to connect to the host via ssh"}
```
- Verify SSH: `ssh deploy@<host> hostname`
- Check `ansible_user` in hosts.yml matches the SSH user
- Ensure the user has passwordless sudo: `echo 'deploy ALL=(ALL) NOPASSWD: ALL' > /etc/sudoers.d/deploy`
### Vault password error
```
ERROR! Decryption failed on ...vault.yml
```
- Verify `.vault_pass` file exists and is correct
- Or pass explicitly: `ansible-playbook ... --vault-password-file /path/to/.vault_pass`
### Deploy fails at "Wait for PostgreSQL"
PostgreSQL hasn't started yet. Check:
```bash
ansible <host> -m command -a "docker compose logs v2-postgres --tail 30" \
--become -e "chdir=/opt/changemaker-lite"
```
Common causes:
- Disk full (`df -h`)
- Wrong `V2_POSTGRES_PASSWORD` (check vault.yml matches what's in the running DB)
- First deploy: PostgreSQL needs time to initialize
### Health check fails after deploy
API not responding on `/api/health`:
```bash
# Check if container is running:
ansible <host> -m command -a "docker compose ps api" --become -e "chdir=/opt/changemaker-lite"
# Check API logs:
ansible <host> -m command -a "docker compose logs api --tail 50" --become -e "chdir=/opt/changemaker-lite"
```
Common causes:
- Missing environment variable (check `.env` generation)
- Database migration failure (check Prisma output)
- Port conflict (another process on 4000)
### .env has wrong values
Compare generated `.env` with expected:
```bash
# Show diff of what Ansible would change:
ansible-playbook playbooks/configure.yml --limit <host> --check --diff
```
### Remote write not working (Tier 2)
```bash
# Check Prometheus config on instance:
ansible <host> -m command -a "cat /opt/changemaker-lite/configs/prometheus/prometheus.yml" --become
# Check Prometheus logs for remote write errors:
ansible <host> -m command -a "docker compose logs prometheus-changemaker --tail 30" \
--become -e "chdir=/opt/changemaker-lite"
```
Common issues:
- `bunker_ops_enabled` not set to `true`
- Wrong `bunker_ops_remote_write_url`
- Invalid auth token
- Central VictoriaMetrics not reachable (firewall, DNS)
---
## 9. Variable Reference
### Configuration variables (`cml_*`)
Set these in `host_vars/<hostname>/main.yml` or `group_vars/`.
| Variable | Default | Description |
|----------|---------|-------------|
| `cml_domain` | `cmlite.org` | Instance domain (drives CORS, SMTP, URLs) |
| `cml_node_env` | `production` | Node.js environment |
| `cml_api_port` | `4000` | Express API port |
| `cml_admin_port` | `3000` | React admin port |
| `cml_media_api_port` | `4100` | Fastify media API port |
| `cml_postgres_port` | `5433` | PostgreSQL host port |
| `cml_enable_media` | `"false"` | Enable video library |
| `cml_enable_payments` | `"false"` | Enable Stripe payments |
| `cml_enable_chat` | `"false"` | Enable Rocket.Chat |
| `cml_listmonk_sync_enabled` | `"false"` | Enable newsletter sync |
| `cml_gancio_sync_enabled` | `"false"` | Enable event sync |
| `cml_email_test_mode` | `"true"` | Use MailHog (`true`) or SMTP (`false`) |
| `cml_monitoring_enabled` | `false` | Enable Prometheus/Grafana stack |
| `cml_smtp_host` | `mailhog-changemaker` | SMTP server hostname |
| `cml_smtp_port` | `1025` | SMTP server port |
| `cml_smtp_user` | `""` | SMTP username |
| `cml_mapbox_api_key` | `""` | Mapbox geocoding key |
| `cml_google_maps_api_key` | `""` | Google Maps geocoding key |
| `cml_pangolin_api_url` | `""` | Pangolin tunnel API |
| `cml_pangolin_org_id` | `""` | Pangolin organization |
| `cml_backup_retention_days` | `30` | Days to keep local backups |
| `cml_backup_cron_hour` | `3` | Backup cron hour (UTC) |
| `cml_backup_s3_enabled` | `false` | Upload backups to S3 |
| `bunker_ops_enabled` | `false` | Enable fleet observability |
| `bunker_ops_instance_label` | `{{ cml_domain }}` | Label in central metrics |
| `bunker_ops_remote_write_url` | `""` | VictoriaMetrics write endpoint |
### Secret variables (`vault_cml_*`)
Set these in `host_vars/<hostname>/vault.yml` (encrypted).
| Variable | Purpose |
|----------|---------|
| `vault_cml_v2_postgres_password` | PostgreSQL password |
| `vault_cml_redis_password` | Redis authentication |
| `vault_cml_jwt_access_secret` | JWT access token signing (64-char hex) |
| `vault_cml_jwt_refresh_secret` | JWT refresh token signing (64-char hex) |
| `vault_cml_encryption_key` | Database field encryption (64-char hex) |
| `vault_cml_initial_admin_email` | Initial admin email |
| `vault_cml_initial_admin_password` | Initial admin password (12+ chars, complexity) |
| `vault_cml_listmonk_db_password` | Listmonk PostgreSQL password |
| `vault_cml_listmonk_web_admin_password` | Listmonk web UI password |
| `vault_cml_listmonk_api_token` | Listmonk API token |
| `vault_cml_nocodb_admin_password` | NocoDB admin password |
| `vault_cml_gitea_db_passwd` | Gitea database password |
| `vault_cml_gitea_db_root_password` | Gitea DB root password |
| `vault_cml_n8n_encryption_key` | n8n encryption key |
| `vault_cml_n8n_user_password` | n8n admin password |
| `vault_cml_grafana_admin_password` | Grafana admin password |
| `vault_cml_gotify_admin_password` | Gotify admin password |
| `vault_cml_vaultwarden_admin_token` | Vaultwarden admin token (64-char hex) |
| `vault_cml_rocketchat_admin_password` | Rocket.Chat admin password |
| `vault_cml_gancio_admin_password` | Gancio admin password |
| `vault_cml_smtp_pass` | SMTP password |
| `vault_cml_pangolin_api_key` | Pangolin API key |
| `vault_cml_pangolin_newt_id` | Pangolin Newt container ID |
| `vault_cml_pangolin_newt_secret` | Pangolin Newt secret |
| `vault_cml_pangolin_site_id` | Pangolin site ID |
| `vault_cml_pangolin_endpoint` | Pangolin endpoint URL |
| `vault_bunker_ops_remote_write_token` | Central VM write auth token |

543
bunker-ops/ROLLOUT_PLAN.md Normal file
View File

@ -0,0 +1,543 @@
# Bunker Ops — Staged Rollout Plan
Full plan for rolling out the fleet management and observability system across Changemaker Lite instances.
---
## Current State (Completed)
### Phase 0: Foundation ✅
**Repo changes (v2 branch):**
- `INSTANCE_LABEL`, `BUNKER_OPS_ENABLED`, `BUNKER_OPS_REMOTE_WRITE_URL` env vars added
- Prometheus metrics tagged with `instance` label
- Redis-exporter auth fixed (correct container name + password)
- Backup script pushes metrics when Bunker Ops is enabled
- `docker-compose.override.yml` in `.gitignore`
**Ansible skeleton (`bunker-ops/`):**
- `ansible.cfg` — SSH pipelining, yaml callback, vault password path
- Inventory structure with example host_vars and group defaults
- 3 roles: `common` (OS/Docker/UFW), `changemaker` (full deploy), `monitoring` (Prometheus/remote_write)
- 5 playbooks: `deploy`, `upgrade`, `backup`, `configure`, `monitoring`
- 2 scripts: `bootstrap-vault.sh` (secret generation), `add-instance.sh` (instance scaffolding)
- `env.j2` template mapping all 100+ `.env` variables to Ansible vars
---
## Phase 1: First Managed Instance (Week 1-2)
**Goal:** Validate the full Ansible pipeline end-to-end on a single real instance.
### 1.1 Prepare a test server
- Provision a fresh Ubuntu 24.04 VM (e.g., a low-cost VPS or local Proxmox VM)
- Set up SSH key access for a `deploy` user with passwordless sudo
- Ensure ports 80, 443, SSH are reachable
### 1.2 Scaffold the instance
```bash
cd bunker-ops
echo "$(openssl rand -base64 32)" > .vault_pass
chmod 600 .vault_pass
./scripts/add-instance.sh test-01 test.cmlite.org <server-ip> --tier 1
```
### 1.3 Run the full deploy
```bash
ansible-playbook playbooks/deploy.yml --limit test-01
```
### 1.4 Validate
- [ ] All containers running (`docker compose ps`)
- [ ] API responds at `/api/health`
- [ ] Admin GUI loads and login works
- [ ] Prisma migrations applied cleanly
- [ ] Backup cron is installed (`crontab -l`)
- [ ] UFW is active with correct rules
- [ ] fail2ban is running
### 1.5 Test day-2 operations
- [ ] `configure.yml` — change a feature flag, verify API restarts
- [ ] `upgrade.yml` — make a Git commit, run upgrade, verify new code is live
- [ ] `backup.yml` — trigger backup, verify archive created
- [ ] Secret rotation — change Redis password in vault, reconfigure, verify connectivity
### 1.6 Fix and iterate
Document anything that fails. Update roles, templates, and defaults. The Ansible skeleton is a starting framework — real deployments will surface edge cases in:
- Docker image pull timing
- Prisma migration ordering
- Directory permission edge cases
- OS-specific package availability
**Deliverable:** One fully Ansible-managed instance running in production.
---
## Phase 2: Pangolin Tunnel Integration (Week 2-3)
**Goal:** Automate the full Pangolin tunnel setup within Ansible.
### 2.1 Add Pangolin setup task
Create `roles/changemaker/tasks/pangolin.yml`:
- Call Pangolin API to create a site (if `cml_pangolin_api_url` is set)
- Store returned `PANGOLIN_SITE_ID`, `PANGOLIN_NEWT_ID`, `PANGOLIN_NEWT_SECRET` in vault
- Sync resource definitions from `configs/pangolin/resources.yml`
- Set all resources to "Not Protected"
- Restart the Newt container
This replaces the manual Pangolin setup flow that currently lives in the admin GUI.
### 2.2 Validate tunnel works
- [ ] Instance accessible via `https://app.<domain>` through Pangolin
- [ ] API accessible via `https://api.<domain>`
- [ ] All 12 subdomains route correctly
- [ ] CORS headers present
### 2.3 Idempotency
Ensure re-running the playbook doesn't duplicate Pangolin resources. The task should check for existing site/resources before creating new ones.
**Deliverable:** Single-command deployment from bare server to publicly accessible instance.
---
## Phase 3: Onboard Existing Instances (Week 3-4)
**Goal:** Migrate manually-installed instances to Ansible management.
### 3.1 Import strategy
For each existing instance that was set up with `config.sh`:
1. **Scaffold host_vars:**
```bash
./scripts/add-instance.sh <hostname> <domain> <ip> --tier 1
```
2. **Import existing secrets** from the server's `.env` into the vault:
```bash
# SSH in and extract current secrets:
ssh deploy@<ip> "grep -E '(PASSWORD|SECRET|KEY|TOKEN)' /opt/changemaker-lite/.env"
# Copy into vault.yml (replace generated values with existing ones)
ansible-vault edit inventory/host_vars/<hostname>/vault.yml
```
3. **Test with `--check --diff`** first:
```bash
ansible-playbook playbooks/configure.yml --limit <hostname> --check --diff
```
This shows what `.env` lines would change without actually changing anything.
4. **Apply configuration management:**
```bash
ansible-playbook playbooks/configure.yml --limit <hostname>
```
### 3.2 Avoid disruption
- **Do NOT re-run the `common` role** on production servers that are already set up. Use `--tags env,deploy` to skip OS provisioning.
- **Do NOT re-run the seed** on instances with existing data. The seed task has `failed_when: false` for safety, but verify.
- **Backup first** — always run `playbooks/backup.yml` before importing an existing instance.
### 3.3 Instance inventory target
| Instance | Domain | Status | Tier |
|----------|--------|--------|------|
| test-01 | test.cmlite.org | Phase 1 deploy | 1 |
| edmonton-prod | betteredmonton.org | Import from config.sh | 1 |
| ... | ... | ... | ... |
Populate this table as instances are onboarded. Aim for 3-5 instances managed by end of Phase 3.
**Deliverable:** All existing production instances under Ansible management (Tier 1).
---
## Phase 4: Central Observability Server (Week 4-6)
**Goal:** Deploy the Bunker Ops central server with VictoriaMetrics, Grafana, and Uptime Kuma.
### 4.1 Create `roles/bunker-ops/`
New role for the central server:
```
roles/bunker-ops/
├── tasks/main.yml
├── templates/
│ ├── docker-compose.yml.j2
│ └── nginx.conf.j2
├── defaults/main.yml
└── handlers/main.yml
```
**Docker Compose stack:**
| Service | Image | Purpose |
|---------|-------|---------|
| VictoriaMetrics | `victoriametrics/victoria-metrics` | Receives `remote_write` from instances, 12-month retention |
| Grafana | `grafana/grafana` | Fleet dashboards, VM as datasource |
| Uptime Kuma | `louislam/uptime-kuma` | HTTP health monitors per instance |
| Nginx | `nginx:alpine` | TLS termination, auth on write endpoint |
**Key configuration:**
- VictoriaMetrics listens on `:8428` for writes, `:8428/select` for queries
- Nginx authenticates `remote_write` requests with Bearer token
- Grafana auto-provisioned with VictoriaMetrics as default datasource
- Uptime Kuma monitors `https://api.<domain>/api/health` for each instance
### 4.2 Create `playbooks/central.yml`
```yaml
- name: Deploy Bunker Ops Central
hosts: bunker_ops_central
become: true
roles:
- common
- bunker-ops
```
### 4.3 Authentication for remote_write
- Generate a shared write token: `openssl rand -hex 32`
- Store in central server's Nginx config (validates incoming `Authorization: Bearer <token>`)
- Distribute same token to all Tier 2 instances via `vault_bunker_ops_remote_write_token`
- This ensures only authorized instances can push metrics
### 4.4 Deploy and verify
```bash
ansible-playbook playbooks/central.yml
```
Verify:
- [ ] VictoriaMetrics accepts test write: `curl -X POST 'https://ops.bnkserve.org/api/v1/write' -H 'Authorization: Bearer <token>' --data-binary 'test_metric{instance="test"} 1'`
- [ ] Grafana accessible at `https://grafana.ops.bnkserve.org`
- [ ] Uptime Kuma accessible and monitoring test instance
**Deliverable:** Central server running VictoriaMetrics + Grafana + Uptime Kuma.
---
## Phase 5: Fleet Dashboards (Week 6-7)
**Goal:** Build three Grafana dashboards for fleet-wide visibility.
### 5.1 Fleet Overview Dashboard
File: `files/grafana/fleet-overview.json`
**Panels:**
- **Stat row:** Total instances up/down — `count(up{job="changemaker-v2-api"} == 1)`
- **Instance table:** All instances with columns for status, p95 latency, email queue depth, active canvass sessions, last backup age
- **Time series — Canvass visits:** `sum(rate(cm_canvass_visits_total[5m])) by (instance)`
- **Time series — Emails sent:** `sum(rate(cm_emails_sent_total[5m])) by (instance)`
- **Time series — HTTP request rate:** `sum(rate(http_requests_total[5m])) by (instance)`
- **Gauge — Fleet email queue:** `sum(cm_email_queue_size) by (instance)`
**Variables:**
- `$instance` — Multi-select, populated from `label_values(up{job="changemaker-v2-api"}, instance)`
### 5.2 Instance Drill-Down Dashboard
File: `files/grafana/instance-drilldown.json`
**Variables:**
- `$instance` — Single-select
**Panel groups:**
- **Health:** API uptime, HTTP error rate, p50/p95/p99 latency
- **Influence:** Emails sent/failed, queue depth, response submissions
- **Canvass:** Active sessions, visits by outcome, shift signups
- **Geocoding:** Cache hit rate, request rate by provider, duration
- **System:** CPU usage, memory, disk I/O, network (from `node_*` metrics)
This mirrors the existing per-instance Grafana dashboards but sources data from VictoriaMetrics.
### 5.3 Backup Status Dashboard
File: `files/grafana/backup-status.json`
**Panels:**
- **Gauge — Time since last backup:** `time() - cm_backup_last_success_timestamp` per instance. Green < 24h, yellow < 48h, red > 48h.
- **Table — Backup sizes:** `cm_backup_size_bytes` per instance with sparkline trend
- **Alert rule — BackupStale:** Fires when any instance hasn't backed up in 25 hours (1h grace past daily cron)
### 5.4 Auto-provisioning
Grafana dashboards auto-provisioned from JSON files via a `dashboards.yml` provisioner config, same pattern as the existing per-instance Grafana setup.
**Deliverable:** Three operational Grafana dashboards showing fleet health, per-instance detail, and backup status.
---
## Phase 6: Promote Instances to Tier 2 (Week 7-8)
**Goal:** Enable fleet observability on all managed instances.
### 6.1 For each instance
1. Update `host_vars/<hostname>/main.yml`:
```yaml
bunker_ops_enabled: true
bunker_ops_remote_write_url: "https://ops.bnkserve.org/api/v1/write"
```
2. Add write token to `host_vars/<hostname>/vault.yml`:
```yaml
vault_bunker_ops_remote_write_token: "<shared-token>"
```
3. Apply:
```bash
ansible-playbook playbooks/monitoring.yml --limit <hostname>
```
### 6.2 Verify data flow
- Check VictoriaMetrics for incoming data: `curl 'https://ops.bnkserve.org/api/v1/query?query=up{instance="<domain>"}'`
- Check Grafana fleet overview shows the new instance
- Verify backup metrics appear after next backup run
### 6.3 Bandwidth audit
Each instance sends ~50 time series at 15s intervals ≈ 200 samples/minute ≈ 12KB/min ≈ 17MB/day. With 10 instances: ~170MB/day. VictoriaMetrics compresses efficiently — expect ~2GB/month total storage for a 10-instance fleet.
**Deliverable:** All instances reporting to central dashboards.
---
## Phase 7: Alerting & Notifications (Week 8-9)
**Goal:** Central alerting for fleet-wide issues.
### 7.1 Alert rules on central VictoriaMetrics
Create `roles/bunker-ops/templates/alerts.yml.j2`:
| Alert | Condition | Severity |
|-------|-----------|----------|
| `InstanceDown` | `up{job="changemaker-v2-api"} == 0` for 5m | critical |
| `HighErrorRate` | `rate(http_requests_total{status_code=~"5.."}[5m]) > 0.1` | warning |
| `EmailQueueBacklog` | `cm_email_queue_size > 100` for 15m | warning |
| `BackupStale` | `time() - cm_backup_last_success_timestamp > 90000` (25h) | critical |
| `DiskSpaceLow` | `node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"} < 0.1` | critical |
| `HighMemoryUsage` | `node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes < 0.1` for 10m | warning |
| `CanvassSessionAbandoned` | `cm_active_canvass_sessions > 20` for 1h | info |
### 7.2 Notification channels
Central Alertmanager routes alerts to:
- **Gotify** — Push notifications to admin phone
- **Email** — Summary digests to fleet admin email
- **Webhook** — Optional Rocket.Chat / Slack integration
### 7.3 Silence rules
- Suppress `InstanceDown` during planned maintenance windows
- Group alerts by instance to avoid notification storms
**Deliverable:** Automated alerts for instance health, backups, and resource exhaustion.
---
## Phase 8: Upgrade Automation & CI (Week 9-11)
**Goal:** Streamline the upgrade pipeline.
### 8.1 Gitea webhook → n8n → Ansible
When a new commit is pushed to the `v2` branch on the central Gitea:
1. **Gitea** fires a webhook to **n8n**
2. **n8n** workflow triggers `ansible-playbook playbooks/upgrade.yml`
3. Rolling upgrade proceeds (25% batches)
4. Health checks gate each batch
5. n8n sends a summary notification
### 8.2 Canary deployment
Add a `canary` group to inventory:
```yaml
all:
children:
canary:
hosts:
test-01:
changemaker_instances:
hosts:
edmonton-prod:
calgary-prod:
...
```
New `playbooks/canary-upgrade.yml`:
1. Upgrade canary instance first
2. Wait 30 minutes
3. Run health checks
4. If healthy, proceed with `upgrade.yml` on remaining instances
5. If unhealthy, alert and stop
### 8.3 Rollback playbook
Create `playbooks/rollback.yml`:
- `git checkout <previous-tag>` on the instance
- `docker compose up -d --build`
- Run health checks
- Requires knowing the previous good commit (store in a fact file per host)
**Deliverable:** Semi-automated upgrade pipeline with canary gates and rollback capability.
---
## Phase 9: Self-Service Instance Provisioning (Week 11-13)
**Goal:** Enable clients to request and receive a new instance with minimal operator intervention.
### 9.1 Provisioning API
Build a lightweight FastAPI or Express service on the central server:
**Endpoints:**
- `POST /api/instances` — Create a new instance (accepts domain, features, tier)
- `GET /api/instances` — List all instances with status
- `GET /api/instances/:id/status` — Health + metrics summary
- `DELETE /api/instances/:id` — Decommission
**Workflow:**
1. API receives request with domain, SSH host, feature flags
2. Runs `add-instance.sh` to scaffold host_vars
3. Triggers `ansible-playbook playbooks/deploy.yml --limit <hostname>`
4. Monitors deployment progress
5. Returns status when deployment completes
### 9.2 Fleet admin dashboard
A simple web UI (could be a dedicated page in the central Grafana or a standalone React app):
- Instance list with health status
- One-click upgrade, backup, configure
- New instance wizard
- Grafana iframe embeds for metrics
### 9.3 DNS automation
If using Pangolin for all instances:
- Pangolin handles DNS + TLS automatically
- The provisioning API creates Pangolin resources as part of deploy
If using Cloudflare or other DNS:
- Add a `roles/dns/` role with Cloudflare API integration
- Automatically create A/CNAME records for all subdomains
**Deliverable:** Operator can provision a new instance with a single API call or form submission.
---
## Phase 10: Multi-Tenant Hardening (Week 13-16)
**Goal:** Security and isolation for a fleet of independent client instances.
### 10.1 Network isolation
Each instance runs on its own server — already isolated at the OS level. Additional hardening:
- UFW rules restrict outbound to essential services only (Docker Hub, Git, SMTP, Pangolin, VictoriaMetrics)
- No inter-instance SSH access
- Central server can SSH to instances, not vice versa
### 10.2 Secret rotation schedule
Automate periodic secret rotation:
| Secret | Rotation frequency | Method |
|--------|-------------------|--------|
| JWT access secret | Quarterly | vault edit + configure playbook |
| Database passwords | Annually | vault edit + full redeploy |
| Redis password | Annually | vault edit + configure playbook |
| Pangolin tokens | On-demand | Re-run Pangolin setup |
| Remote write token | Annually | Update central + all instances |
Create a `playbooks/rotate-secrets.yml` that generates new secrets and applies them.
### 10.3 Audit logging
- Ansible logs all operations to a central log file
- Each playbook run produces a summary (host, timestamp, changes made)
- Integrate with Git: all inventory changes are committed to a private repo
### 10.4 Compliance documentation
For each instance, Ansible can generate:
- Inventory of services and versions
- Security posture report (UFW rules, fail2ban status, TLS cert expiry)
- Backup compliance (last backup date, retention policy)
- Data residency confirmation (server location, no PII in metrics)
**Deliverable:** Hardened fleet with automated rotation, audit trail, and compliance artifacts.
---
## Timeline Summary
| Phase | Duration | Milestone |
|-------|----------|-----------|
| 0: Foundation | ✅ Done | Ansible skeleton + repo changes |
| 1: First instance | Week 1-2 | End-to-end deploy validated |
| 2: Pangolin integration | Week 2-3 | Single-command public deployment |
| 3: Import existing | Week 3-4 | All instances under management |
| 4: Central server | Week 4-6 | VictoriaMetrics + Grafana running |
| 5: Fleet dashboards | Week 6-7 | 3 operational dashboards |
| 6: Tier 2 promotion | Week 7-8 | All instances reporting centrally |
| 7: Alerting | Week 8-9 | Automated health + backup alerts |
| 8: CI/Upgrade automation | Week 9-11 | Canary + rolling upgrades |
| 9: Self-service | Week 11-13 | Provisioning API + admin UI |
| 10: Multi-tenant hardening | Week 13-16 | Rotation, audit, compliance |
**Total: ~16 weeks from foundation to fully hardened fleet.**
Phases 1-3 are the critical path — they validate the core pipeline and bring existing instances under management. Phases 4-7 add observability. Phases 8-10 are operational maturity.
---
## FOSS Stack Summary
Every component is Free and Open Source Software:
| Component | License | Role in Stack |
|-----------|---------|---------------|
| Ansible | GPL-3.0 | Deployment automation & configuration management |
| VictoriaMetrics | Apache-2.0 | Centralized time-series database (Prometheus-compatible) |
| Grafana | AGPL-3.0 | Fleet dashboards & visualization |
| Uptime Kuma | MIT | HTTP health monitoring |
| Prometheus | Apache-2.0 | Per-instance metrics collection (existing) |
| Alertmanager | Apache-2.0 | Alert routing & deduplication |
| Docker + Compose | Apache-2.0 | Container orchestration |
| Ubuntu | Various FOSS | Host operating system |
| UFW / iptables | GPL | Firewall |
| fail2ban | GPL-2.0 | Brute-force protection |
| OpenSSL | Apache-2.0 | Secret generation |
No proprietary SaaS dependencies. The entire fleet can run air-gapped after initial image pulls.
---
## Risk Register
| Risk | Impact | Mitigation |
|------|--------|------------|
| Vault password lost | Cannot decrypt any secrets | Store in password manager + offline backup |
| Central server down | No fleet dashboards (instances unaffected) | `remote_write` WAL retries for ~2h; instances self-sufficient |
| SSH key compromise | Attacker gains access to managed servers | Rotate keys, use separate deploy user, enable 2FA on SSH |
| Ansible playbook bug | Bad config deployed to fleet | `serial: 1` for deploys, `--check --diff` before apply, canary phase |
| Docker Hub rate limits | Image pulls fail during upgrade | Use a registry mirror or pre-pull images |
| Prisma migration conflict | Database schema mismatch | Always run `migrate deploy` (applies pending only), never `migrate dev` in production |
| Instance disk full | Backup fails, containers crash | `BackupStale` + `DiskSpaceLow` alerts, retention cleanup |

18
bunker-ops/ansible.cfg Normal file
View File

@ -0,0 +1,18 @@
[defaults]
inventory = inventory/hosts.yml
roles_path = roles
vault_password_file = .vault_pass
host_key_checking = False
retry_files_enabled = False
stdout_callback = yaml
forks = 10
timeout = 30
[privilege_escalation]
become = True
become_method = sudo
become_ask_pass = False
[ssh_connection]
pipelining = True
ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o StrictHostKeyChecking=no

View File

@ -0,0 +1,29 @@
---
# Shared defaults for all hosts
# Git repository
cml_repo_url: "https://github.com/bunker-admin/changemaker.lite.git"
cml_repo_branch: v2
# Deployment paths
cml_deploy_path: /opt/changemaker-lite
cml_backup_path: /opt/changemaker-lite/backups
cml_data_path: /opt/changemaker-lite/data
# System packages
common_packages:
- curl
- wget
- git
- htop
- jq
- unzip
- ufw
- fail2ban
- logrotate
# Docker
docker_compose_version: "v2.27.0"
# SSH
ssh_port: 22

View File

@ -0,0 +1,52 @@
---
# Default values for all Changemaker instances
# Override per-host in host_vars/<hostname>/main.yml
# --- Core ---
cml_node_env: production
cml_domain: cmlite.org
# --- Ports (internal defaults, rarely changed) ---
cml_api_port: 4000
cml_admin_port: 3000
cml_media_api_port: 4100
cml_postgres_port: 5433
# --- Feature Flags ---
cml_enable_media: "false"
cml_enable_payments: "false"
cml_enable_chat: "false"
cml_listmonk_sync_enabled: "false"
cml_gancio_sync_enabled: "false"
cml_email_test_mode: "true"
# --- Monitoring ---
cml_monitoring_enabled: false
# --- Bunker Ops ---
bunker_ops_enabled: false
bunker_ops_instance_label: "{{ cml_domain }}"
bunker_ops_remote_write_url: ""
bunker_ops_remote_write_token: ""
# --- Backup ---
cml_backup_retention_days: 30
cml_backup_cron_hour: 3
cml_backup_cron_minute: 0
cml_backup_s3_enabled: false
# --- SMTP (defaults to MailHog) ---
cml_smtp_host: mailhog-changemaker
cml_smtp_port: 1025
cml_smtp_user: ""
cml_smtp_pass: ""
# --- Geocoding ---
cml_mapbox_api_key: ""
cml_google_maps_api_key: ""
cml_google_maps_enabled: "false"
# --- Pangolin ---
cml_pangolin_api_url: ""
cml_pangolin_api_key: ""
cml_pangolin_org_id: ""

View File

@ -0,0 +1,28 @@
---
# Example host_vars — copy this directory for each new instance
# Rename to match the hostname in hosts.yml
cml_domain: example.org
cml_node_env: production
# Feature toggles (override group defaults)
cml_enable_media: "true"
cml_listmonk_sync_enabled: "true"
cml_email_test_mode: "false"
cml_monitoring_enabled: true
# SMTP (production)
cml_smtp_host: smtp.example.org
cml_smtp_port: 587
cml_smtp_user: "noreply@example.org"
# cml_smtp_pass is in vault.yml
# Pangolin tunnel
cml_pangolin_api_url: "https://api.bnkserve.org/v1"
# cml_pangolin_api_key is in vault.yml
cml_pangolin_org_id: "org_example"
# Bunker Ops (Tier 2 — fleet observability)
bunker_ops_enabled: true
bunker_ops_remote_write_url: "https://ops.bnkserve.org/api/v1/write"
# bunker_ops_remote_write_token is in vault.yml

View File

@ -0,0 +1,63 @@
---
# EXAMPLE — Replace with real values, then encrypt with:
# ansible-vault encrypt inventory/host_vars/example-instance/vault.yml
#
# Or generate all secrets automatically:
# ./scripts/bootstrap-vault.sh example-instance
# --- Database ---
vault_cml_v2_postgres_password: "GENERATE_ME"
# --- Redis ---
vault_cml_redis_password: "GENERATE_ME"
# --- JWT & Encryption ---
vault_cml_jwt_access_secret: "GENERATE_ME"
vault_cml_jwt_refresh_secret: "GENERATE_ME"
vault_cml_encryption_key: "GENERATE_ME"
# --- Admin ---
vault_cml_initial_admin_email: "admin@example.org"
vault_cml_initial_admin_password: "GENERATE_ME"
# --- Listmonk ---
vault_cml_listmonk_db_password: "GENERATE_ME"
vault_cml_listmonk_web_admin_password: "GENERATE_ME"
vault_cml_listmonk_api_token: "GENERATE_ME"
# --- NocoDB ---
vault_cml_nocodb_admin_password: "GENERATE_ME"
# --- Gitea ---
vault_cml_gitea_db_passwd: "GENERATE_ME"
vault_cml_gitea_db_root_password: "GENERATE_ME"
# --- n8n ---
vault_cml_n8n_encryption_key: "GENERATE_ME"
vault_cml_n8n_user_password: "GENERATE_ME"
# --- Monitoring ---
vault_cml_grafana_admin_password: "GENERATE_ME"
vault_cml_gotify_admin_password: "GENERATE_ME"
# --- Vaultwarden ---
vault_cml_vaultwarden_admin_token: "GENERATE_ME"
# --- Rocket.Chat ---
vault_cml_rocketchat_admin_password: "GENERATE_ME"
# --- Gancio ---
vault_cml_gancio_admin_password: "GENERATE_ME"
# --- SMTP ---
vault_cml_smtp_pass: ""
# --- Pangolin ---
vault_cml_pangolin_api_key: ""
vault_cml_pangolin_newt_id: ""
vault_cml_pangolin_newt_secret: ""
vault_cml_pangolin_site_id: ""
vault_cml_pangolin_endpoint: ""
# --- Bunker Ops ---
vault_bunker_ops_remote_write_token: ""

View File

@ -0,0 +1,26 @@
---
# Bunker Ops — Fleet Inventory
# Add instances under changemaker_instances group
# Each host needs a matching host_vars/<hostname>/ directory
all:
children:
changemaker_instances:
hosts:
# Example: Uncomment and customize per-instance
# edmonton-prod:
# ansible_host: 10.0.1.10
# ansible_user: deploy
# cml_domain: betteredmonton.org
#
# calgary-staging:
# ansible_host: 10.0.2.20
# ansible_user: deploy
# cml_domain: staging.bettercalgary.org
# Central Bunker Ops server (future — VictoriaMetrics, Grafana, Uptime Kuma)
# bunker_ops_central:
# hosts:
# ops-server:
# ansible_host: 10.0.0.1
# ansible_user: deploy

View File

@ -0,0 +1,19 @@
---
# Trigger backups across all instances
# Usage: ansible-playbook playbooks/backup.yml [--limit hostname]
- name: Run Changemaker Lite backups
hosts: changemaker_instances
become: true
tasks:
- name: Run backup script
ansible.builtin.command:
cmd: ./scripts/backup.sh --retention {{ cml_backup_retention_days }}
chdir: "{{ cml_deploy_path }}"
register: backup_result
changed_when: "'Backup complete' in backup_result.stdout"
- name: Show backup result
ansible.builtin.debug:
msg: "{{ backup_result.stdout_lines | last }}"

View File

@ -0,0 +1,36 @@
---
# Update .env configuration without full redeploy
# Usage: ansible-playbook playbooks/configure.yml [--limit hostname]
# Regenerates .env + services.yaml and restarts API
- name: Reconfigure Changemaker Lite
hosts: changemaker_instances
become: true
tasks:
- name: Regenerate .env
ansible.builtin.template:
src: "{{ playbook_dir }}/../roles/changemaker/templates/env.j2"
dest: "{{ cml_deploy_path }}/.env"
mode: "0600"
backup: true
register: env_result
- name: Regenerate Homepage services.yaml
ansible.builtin.template:
src: "{{ playbook_dir }}/../roles/changemaker/templates/services.yaml.j2"
dest: "{{ cml_deploy_path }}/configs/homepage/services.yaml"
mode: "0644"
- name: Restart API to pick up new config
ansible.builtin.command:
cmd: docker compose restart api
chdir: "{{ cml_deploy_path }}"
when: env_result.changed
changed_when: true
- name: Configuration summary
ansible.builtin.debug:
msg: |
Configuration {{ 'updated' if env_result.changed else 'unchanged' }} for {{ cml_domain }}
{{ 'API restarted to apply changes' if env_result.changed else 'No restart needed' }}

View File

@ -0,0 +1,43 @@
---
# Full initial deployment of Changemaker Lite instances
# Usage: ansible-playbook playbooks/deploy.yml [--limit hostname]
- name: Deploy Changemaker Lite
hosts: changemaker_instances
serial: 1 # One at a time for initial deploys
become: true
pre_tasks:
- name: Validate required vault variables
ansible.builtin.assert:
that:
- vault_cml_v2_postgres_password is defined
- vault_cml_v2_postgres_password != 'GENERATE_ME'
- vault_cml_redis_password is defined
- vault_cml_redis_password != 'GENERATE_ME'
- vault_cml_jwt_access_secret is defined
- vault_cml_jwt_access_secret != 'GENERATE_ME'
- vault_cml_encryption_key is defined
- vault_cml_encryption_key != 'GENERATE_ME'
- vault_cml_initial_admin_password is defined
- vault_cml_initial_admin_password != 'GENERATE_ME'
fail_msg: >
Required secrets not configured. Run:
./scripts/bootstrap-vault.sh {{ inventory_hostname }}
quiet: true
roles:
- common
- changemaker
- role: monitoring
when: cml_monitoring_enabled | bool
post_tasks:
- name: Deployment summary
ansible.builtin.debug:
msg: |
Deployment complete for {{ cml_domain }}
Admin: https://app.{{ cml_domain }}
API: https://api.{{ cml_domain }}
Monitoring: {{ 'enabled' if cml_monitoring_enabled | bool else 'disabled' }}
Bunker Ops: {{ 'Tier 2 (fleet)' if bunker_ops_enabled | bool else 'Standalone' }}

View File

@ -0,0 +1,18 @@
---
# Enable or reconfigure monitoring on instances
# Usage: ansible-playbook playbooks/monitoring.yml [--limit hostname]
- name: Configure Monitoring
hosts: changemaker_instances
become: true
roles:
- monitoring
post_tasks:
- name: Monitoring summary
ansible.builtin.debug:
msg: |
Monitoring configured for {{ cml_domain }}
Profile: {{ 'enabled' if cml_monitoring_enabled | bool else 'disabled' }}
Remote write: {{ 'enabled → ' + bunker_ops_remote_write_url if bunker_ops_enabled | bool else 'disabled' }}

View File

@ -0,0 +1,78 @@
---
# Rolling upgrade of Changemaker Lite instances
# Usage: ansible-playbook playbooks/upgrade.yml [--limit hostname]
# Pulls latest code, rebuilds images, runs migrations, restarts
- name: Upgrade Changemaker Lite
hosts: changemaker_instances
serial: "25%" # Rolling upgrade in batches
become: true
tasks:
- name: Pull latest code
ansible.builtin.git:
repo: "{{ cml_repo_url }}"
dest: "{{ cml_deploy_path }}"
version: "{{ cml_repo_branch }}"
force: false
update: true
register: git_result
- name: Regenerate .env (pick up new vars)
ansible.builtin.template:
src: "{{ playbook_dir }}/../roles/changemaker/templates/env.j2"
dest: "{{ cml_deploy_path }}/.env"
mode: "0600"
backup: true
- name: Pull updated Docker images
ansible.builtin.command:
cmd: docker compose pull
chdir: "{{ cml_deploy_path }}"
changed_when: true
- name: Rebuild custom images
ansible.builtin.command:
cmd: docker compose build --no-cache
chdir: "{{ cml_deploy_path }}"
changed_when: true
when: git_result.changed
- name: Apply database migrations
ansible.builtin.command:
cmd: docker compose exec -T api npx prisma migrate deploy
chdir: "{{ cml_deploy_path }}"
register: migrate_result
changed_when: "'applied' in migrate_result.stdout"
- name: Restart stack with new images
ansible.builtin.command:
cmd: docker compose up -d --remove-orphans
chdir: "{{ cml_deploy_path }}"
changed_when: true
- name: Restart monitoring (if enabled)
ansible.builtin.command:
cmd: docker compose --profile monitoring up -d
chdir: "{{ cml_deploy_path }}"
when: cml_monitoring_enabled | bool
changed_when: true
- name: Wait for API health
ansible.builtin.uri:
url: "http://localhost:{{ cml_api_port }}/api/health"
method: GET
status_code: 200
timeout: 5
register: health
retries: 15
delay: 3
until: health.status == 200
- name: Upgrade summary
ansible.builtin.debug:
msg: |
Upgraded {{ cml_domain }}
Git: {{ git_result.before[:8] | default('?') }} → {{ git_result.after[:8] | default('?') }}
Migrations: {{ migrate_result.stdout_lines | default([]) | length }} applied
API health: OK

View File

@ -0,0 +1,53 @@
---
# Changemaker role defaults
# PostgreSQL
cml_v2_postgres_user: changemaker
cml_v2_postgres_db: changemaker_v2
# JWT
cml_jwt_access_expiry: "15m"
cml_jwt_refresh_expiry: "7d"
# User/group IDs (match host deploy user)
cml_user_id: 1000
cml_group_id: 1000
cml_docker_group_id: 984
# Listmonk
cml_listmonk_admin_user: admin
cml_listmonk_smtp_tls_type: STARTTLS
# NocoDB
cml_nocodb_port: 8091
# Gitea
cml_gitea_port: 3030
# n8n
cml_n8n_port: 5678
# Gancio
cml_gancio_port: 8092
cml_gancio_admin_user: admin
# Vaultwarden
cml_vaultwarden_port: 8445
# Directories that must exist before docker compose up
cml_required_dirs:
- configs/code-server/.config
- configs/code-server/.local
- configs/homepage/logs
- mkdocs/.cache
- mkdocs/site
- assets/uploads
- assets/images
- assets/icons
- media/local/inbox
- media/local/thumbnails
- media/public
- local-files
- data
- backups
- logs

View File

@ -0,0 +1,18 @@
---
- name: Restart Changemaker stack
ansible.builtin.command:
cmd: docker compose up -d --remove-orphans
chdir: "{{ cml_deploy_path }}"
changed_when: true
- name: Restart API only
ansible.builtin.command:
cmd: docker compose restart api
chdir: "{{ cml_deploy_path }}"
changed_when: true
- name: Restart Nginx only
ansible.builtin.command:
cmd: docker compose restart nginx
chdir: "{{ cml_deploy_path }}"
changed_when: true

View File

@ -0,0 +1,15 @@
---
# Configure automated backup cron job
- name: Ensure backup script is executable
ansible.builtin.file:
path: "{{ cml_deploy_path }}/scripts/backup.sh"
mode: "0755"
- name: Configure daily backup cron
ansible.builtin.cron:
name: "changemaker-lite-backup"
minute: "{{ cml_backup_cron_minute }}"
hour: "{{ cml_backup_cron_hour }}"
job: "cd {{ cml_deploy_path }} && ./scripts/backup.sh{% if cml_backup_s3_enabled %} --s3{% endif %} --retention {{ cml_backup_retention_days }} >> {{ cml_deploy_path }}/logs/backup.log 2>&1"
user: "{{ ansible_user }}"

View File

@ -0,0 +1,21 @@
---
# Clone or update the Changemaker Lite repository
- name: Ensure deploy directory exists
ansible.builtin.file:
path: "{{ cml_deploy_path }}"
state: directory
mode: "0755"
- name: Clone repository
ansible.builtin.git:
repo: "{{ cml_repo_url }}"
dest: "{{ cml_deploy_path }}"
version: "{{ cml_repo_branch }}"
force: false
update: true
register: git_result
- name: Show git status
ansible.builtin.debug:
msg: "Repository {{ 'updated' if git_result.changed else 'unchanged' }} at {{ git_result.after[:8] | default('unknown') }}"

View File

@ -0,0 +1,54 @@
---
# Deploy Docker Compose stack
- name: Pull latest images
ansible.builtin.command:
cmd: docker compose pull
chdir: "{{ cml_deploy_path }}"
changed_when: true
tags: [pull]
- name: Build custom images
ansible.builtin.command:
cmd: docker compose build --no-cache
chdir: "{{ cml_deploy_path }}"
changed_when: true
tags: [build]
- name: Start core services
ansible.builtin.command:
cmd: docker compose up -d --remove-orphans
chdir: "{{ cml_deploy_path }}"
changed_when: true
- name: Wait for PostgreSQL to be ready
ansible.builtin.command:
cmd: docker compose exec -T v2-postgres pg_isready -U {{ cml_v2_postgres_user }}
chdir: "{{ cml_deploy_path }}"
register: pg_ready
retries: 15
delay: 2
until: pg_ready.rc == 0
changed_when: false
- name: Run Prisma migrations
ansible.builtin.command:
cmd: docker compose exec -T api npx prisma migrate deploy
chdir: "{{ cml_deploy_path }}"
register: migrate_result
changed_when: "'applied' in migrate_result.stdout"
- name: Run database seed (first deploy only)
ansible.builtin.command:
cmd: docker compose exec -T api npx prisma db seed
chdir: "{{ cml_deploy_path }}"
register: seed_result
changed_when: "'created' in seed_result.stdout"
failed_when: false
- name: Start monitoring services (if enabled)
ansible.builtin.command:
cmd: docker compose --profile monitoring up -d
chdir: "{{ cml_deploy_path }}"
when: cml_monitoring_enabled | bool
changed_when: true

View File

@ -0,0 +1,20 @@
---
# Create all directories required by Docker containers
- name: Create required directories
ansible.builtin.file:
path: "{{ cml_deploy_path }}/{{ item }}"
state: directory
mode: "0775"
loop: "{{ cml_required_dirs }}"
- name: Ensure .gitkeep files exist
ansible.builtin.copy:
content: ""
dest: "{{ cml_deploy_path }}/{{ item }}/.gitkeep"
force: false
mode: "0644"
loop:
- configs/code-server/.config
- configs/code-server/.local
- configs/homepage/logs

View File

@ -0,0 +1,14 @@
---
# Generate .env from Jinja2 template (replaces config.sh)
- name: Generate .env file
ansible.builtin.template:
src: env.j2
dest: "{{ cml_deploy_path }}/.env"
mode: "0600"
backup: true
register: env_result
- name: Report .env status
ansible.builtin.debug:
msg: ".env {{ 'updated' if env_result.changed else 'unchanged' }}"

View File

@ -0,0 +1,38 @@
---
# Post-deploy health checks
- name: Wait for API to respond
ansible.builtin.uri:
url: "http://localhost:{{ cml_api_port }}/api/health"
method: GET
status_code: 200
timeout: 5
register: api_health
retries: 15
delay: 3
until: api_health.status == 200
- name: Wait for Admin GUI to respond
ansible.builtin.uri:
url: "http://localhost:{{ cml_admin_port }}"
method: GET
status_code: 200
timeout: 5
register: admin_health
retries: 10
delay: 3
until: admin_health.status == 200
- name: Check container health
ansible.builtin.command:
cmd: docker compose ps --format json
chdir: "{{ cml_deploy_path }}"
register: compose_ps
changed_when: false
- name: Report deployment status
ansible.builtin.debug:
msg: |
Deployment health check:
API: {{ 'OK' if api_health.status == 200 else 'FAILED' }}
Admin: {{ 'OK' if admin_health.status == 200 else 'FAILED' }}

View File

@ -0,0 +1,30 @@
---
# Changemaker role — Full deployment orchestration
- name: Clone/update repository
ansible.builtin.include_tasks: clone.yml
tags: [clone, deploy]
- name: Create required directories
ansible.builtin.include_tasks: dirs.yml
tags: [dirs, deploy]
- name: Generate .env configuration
ansible.builtin.include_tasks: env.yml
tags: [env, configure, deploy]
- name: Generate Homepage services.yaml
ansible.builtin.include_tasks: services.yml
tags: [services, configure, deploy]
- name: Deploy Docker stack
ansible.builtin.include_tasks: deploy.yml
tags: [deploy]
- name: Run health checks
ansible.builtin.include_tasks: health.yml
tags: [health, deploy]
- name: Configure backup cron
ansible.builtin.include_tasks: backup.yml
tags: [backup, deploy]

View File

@ -0,0 +1,14 @@
---
# Generate Homepage services.yaml from template
- name: Ensure Homepage config directory exists
ansible.builtin.file:
path: "{{ cml_deploy_path }}/configs/homepage"
state: directory
mode: "0755"
- name: Generate Homepage services.yaml
ansible.builtin.template:
src: services.yaml.j2
dest: "{{ cml_deploy_path }}/configs/homepage/services.yaml"
mode: "0644"

View File

@ -0,0 +1,195 @@
# ==============================================================================
# Changemaker Lite v2 — Environment Variables
# Generated by Ansible (Bunker Ops) — DO NOT EDIT MANUALLY
# Instance: {{ cml_domain }}
# Generated: {{ ansible_date_time.iso8601 }}
# ==============================================================================
# --- General ---
NODE_ENV={{ cml_node_env }}
DOMAIN={{ cml_domain }}
USER_ID={{ cml_user_id }}
GROUP_ID={{ cml_group_id }}
DOCKER_GROUP_ID={{ cml_docker_group_id }}
# --- V2 PostgreSQL ---
V2_POSTGRES_USER={{ cml_v2_postgres_user }}
V2_POSTGRES_PASSWORD={{ vault_cml_v2_postgres_password }}
V2_POSTGRES_DB={{ cml_v2_postgres_db }}
V2_POSTGRES_PORT={{ cml_postgres_port }}
DATABASE_URL=postgresql://{{ cml_v2_postgres_user }}:{{ vault_cml_v2_postgres_password }}@localhost:{{ cml_postgres_port }}/{{ cml_v2_postgres_db }}
# --- Redis ---
REDIS_PASSWORD={{ vault_cml_redis_password }}
REDIS_URL=redis://:{{ vault_cml_redis_password }}@redis-changemaker:6379
# --- JWT Auth ---
JWT_ACCESS_SECRET={{ vault_cml_jwt_access_secret }}
JWT_REFRESH_SECRET={{ vault_cml_jwt_refresh_secret }}
JWT_ACCESS_EXPIRY={{ cml_jwt_access_expiry }}
JWT_REFRESH_EXPIRY={{ cml_jwt_refresh_expiry }}
# --- Encryption ---
ENCRYPTION_KEY={{ vault_cml_encryption_key }}
# --- Initial Super Admin ---
INITIAL_ADMIN_EMAIL={{ vault_cml_initial_admin_email | default('admin@' + cml_domain) }}
INITIAL_ADMIN_PASSWORD={{ vault_cml_initial_admin_password }}
# --- API ---
API_PORT={{ cml_api_port }}
API_URL=http://localhost:{{ cml_api_port }}
CORS_ORIGINS=http://app.{{ cml_domain }},https://app.{{ cml_domain }},http://{{ cml_domain }},https://{{ cml_domain }},http://localhost:3000,http://localhost,http://localhost:4003
# --- Admin ---
ADMIN_URL=http://localhost:{{ cml_admin_port }}
# --- SMTP ---
SMTP_HOST={{ cml_smtp_host }}
SMTP_PORT={{ cml_smtp_port }}
SMTP_USER={{ cml_smtp_user }}
SMTP_PASS={{ vault_cml_smtp_pass | default('') }}
SMTP_FROM=noreply@{{ cml_domain }}
SMTP_FROM_NAME=Changemaker Lite
EMAIL_TEST_MODE={{ cml_email_test_mode }}
TEST_EMAIL_RECIPIENT={{ vault_cml_initial_admin_email | default('admin@' + cml_domain) }}
# --- Listmonk ---
LISTMONK_URL=http://listmonk-app:9000
LISTMONK_ADMIN_USER={{ cml_listmonk_admin_user }}
LISTMONK_ADMIN_PASSWORD={{ vault_cml_listmonk_api_token }}
LISTMONK_SYNC_ENABLED={{ cml_listmonk_sync_enabled }}
LISTMONK_WEBHOOK_SECRET={{ vault_cml_listmonk_api_token }}
LISTMONK_DB_HOST=listmonk-db
LISTMONK_DB_PORT=5432
LISTMONK_DB_USER=listmonk
LISTMONK_DB_PASSWORD={{ vault_cml_listmonk_db_password }}
LISTMONK_DB_NAME=listmonk
LISTMONK_WEB_ADMIN_USER=admin
LISTMONK_WEB_ADMIN_PASSWORD={{ vault_cml_listmonk_web_admin_password }}
LISTMONK_API_USER=api
LISTMONK_API_TOKEN={{ vault_cml_listmonk_api_token }}
LISTMONK_SMTP_HOST={{ cml_smtp_host }}
LISTMONK_SMTP_PORT={{ cml_smtp_port }}
LISTMONK_SMTP_USER={{ cml_smtp_user }}
LISTMONK_SMTP_PASSWORD={{ vault_cml_smtp_pass | default('') }}
LISTMONK_SMTP_TLS_TYPE={{ cml_listmonk_smtp_tls_type }}
LISTMONK_SMTP_FROM=Changemaker Lite <noreply@{{ cml_domain }}>
# --- Represent API ---
REPRESENT_API_URL=https://represent.opennorth.ca
# --- Geocoding ---
{% if cml_mapbox_api_key %}
MAPBOX_API_KEY={{ cml_mapbox_api_key }}
{% endif %}
GEOCODING_RATE_LIMIT_MS=1100
GEOCODING_CACHE_ENABLED=true
GEOCODING_CACHE_TTL_HOURS=24
{% if cml_google_maps_api_key %}
GOOGLE_MAPS_API_KEY={{ cml_google_maps_api_key }}
{% endif %}
GOOGLE_MAPS_ENABLED={{ cml_google_maps_enabled }}
GEOCODING_PARALLEL_ENABLED=true
GEOCODING_BATCH_SIZE=10
BULK_GEOCODE_ENABLED=true
BULK_GEOCODE_MAX_BATCH=5000
# --- Platform Services ---
NOCODB_URL=http://changemaker-v2-nocodb:8080
NOCODB_PORT={{ cml_nocodb_port }}
NC_ADMIN_EMAIL={{ vault_cml_initial_admin_email | default('admin@' + cml_domain) }}
NC_ADMIN_PASSWORD={{ vault_cml_nocodb_admin_password }}
N8N_URL=http://n8n-changemaker:5678
N8N_PORT={{ cml_n8n_port }}
N8N_HOST=n8n.{{ cml_domain }}
N8N_ENCRYPTION_KEY={{ vault_cml_n8n_encryption_key }}
N8N_USER_EMAIL={{ vault_cml_initial_admin_email | default('admin@' + cml_domain) }}
N8N_USER_PASSWORD={{ vault_cml_n8n_user_password }}
GITEA_URL=http://gitea-changemaker:3000
GITEA_PORT={{ cml_gitea_port }}
GITEA_ROOT_URL=https://git.{{ cml_domain }}
GITEA_DOMAIN=git.{{ cml_domain }}
GITEA_DB_PASSWD={{ vault_cml_gitea_db_passwd }}
GITEA_DB_ROOT_PASSWORD={{ vault_cml_gitea_db_root_password }}
# --- MailHog ---
MAILHOG_URL=http://mailhog-changemaker:8025
# --- Mini QR ---
MINI_QR_URL=http://mini-qr:8080
MINI_QR_PORT=8089
# --- Excalidraw ---
EXCALIDRAW_URL=http://excalidraw-changemaker:80
EXCALIDRAW_PORT=8090
EXCALIDRAW_WS_URL=wss://draw.{{ cml_domain }}
# --- Homepage ---
HOMEPAGE_URL=http://homepage-changemaker:3000
HOMEPAGE_VAR_BASE_URL=https://{{ cml_domain }}
# --- Vaultwarden ---
VAULTWARDEN_URL=http://vaultwarden-changemaker:80
VAULTWARDEN_DOMAIN=https://vault.{{ cml_domain }}
VAULTWARDEN_ADMIN_TOKEN={{ vault_cml_vaultwarden_admin_token }}
# --- Rocket.Chat ---
ROCKETCHAT_URL=http://rocketchat-changemaker:3000
ROCKETCHAT_ADMIN_USER=admin
ROCKETCHAT_ADMIN_PASSWORD={{ vault_cml_rocketchat_admin_password }}
ENABLE_CHAT={{ cml_enable_chat }}
# --- Gancio ---
GANCIO_PORT={{ cml_gancio_port }}
GANCIO_URL=http://gancio-changemaker:13120
GANCIO_BASE_URL=https://events.{{ cml_domain }}
GANCIO_ADMIN_USER={{ cml_gancio_admin_user }}
GANCIO_ADMIN_PASSWORD={{ vault_cml_gancio_admin_password }}
GANCIO_SYNC_ENABLED={{ cml_gancio_sync_enabled }}
# --- Pangolin ---
PANGOLIN_API_URL={{ cml_pangolin_api_url }}
PANGOLIN_API_KEY={{ vault_cml_pangolin_api_key | default('') }}
PANGOLIN_ORG_ID={{ cml_pangolin_org_id }}
PANGOLIN_SITE_ID={{ vault_cml_pangolin_site_id | default('') }}
PANGOLIN_ENDPOINT={{ vault_cml_pangolin_endpoint | default('') }}
PANGOLIN_NEWT_ID={{ vault_cml_pangolin_newt_id | default('') }}
PANGOLIN_NEWT_SECRET={{ vault_cml_pangolin_newt_secret | default('') }}
# --- NAR ---
NAR_DATA_DIR=/data
# --- Payments ---
ENABLE_PAYMENTS={{ cml_enable_payments }}
# --- Media ---
ENABLE_MEDIA_FEATURES={{ cml_enable_media }}
MEDIA_API_PORT={{ cml_media_api_port }}
MEDIA_API_PUBLIC_URL=http://media-api:{{ cml_media_api_port }}
# --- Docs / Code Server ---
CODE_SERVER_URL=http://code-server-changemaker:8080
CODE_SERVER_PORT=8888
MKDOCS_PREVIEW_URL=http://mkdocs-changemaker:8000
MKDOCS_PORT=4003
# --- Monitoring ---
PROMETHEUS_PORT=9090
GRAFANA_PORT=3005
GRAFANA_ADMIN_PASSWORD={{ vault_cml_grafana_admin_password }}
GRAFANA_ROOT_URL=http://localhost:3005
CADVISOR_PORT=8086
NODE_EXPORTER_PORT=9100
REDIS_EXPORTER_PORT=9121
ALERTMANAGER_PORT=9093
GOTIFY_PORT=8889
GOTIFY_ADMIN_USER=admin
GOTIFY_ADMIN_PASSWORD={{ vault_cml_gotify_admin_password }}
# --- Bunker Ops (Fleet Management) ---
INSTANCE_LABEL={{ bunker_ops_instance_label | default(cml_domain) }}
BUNKER_OPS_ENABLED={{ bunker_ops_enabled | string | lower }}
BUNKER_OPS_REMOTE_WRITE_URL={{ bunker_ops_remote_write_url }}

View File

@ -0,0 +1,127 @@
---
# Homepage Services Configuration — Generated by Ansible (Bunker Ops)
# Instance: {{ cml_domain }}
- Production - Core:
- Admin GUI:
icon: mdi-view-dashboard
href: "https://app.{{ cml_domain }}"
description: Application dashboard and public pages
container: changemaker-v2-admin
- API:
icon: mdi-api
href: "https://api.{{ cml_domain }}"
description: V2 REST API
container: changemaker-v2-api
- Media API:
icon: mdi-video
href: "https://media.{{ cml_domain }}"
description: Video library and streaming
container: changemaker-media-api
- Main Site:
icon: mdi-web
href: "https://{{ cml_domain }}"
description: Documentation and marketing site
container: mkdocs-site-server-changemaker
- Production - Tools:
- Code Server:
icon: mdi-code-braces
href: "https://code.{{ cml_domain }}"
description: VS Code in the browser
container: code-server-changemaker
- NocoDB:
icon: mdi-database
href: "https://db.{{ cml_domain }}"
description: Database browser (read-only)
container: changemaker-v2-nocodb
- MkDocs (Live):
icon: mdi-book-open-page-variant
href: "https://docs.{{ cml_domain }}"
description: Live documentation with hot reload
container: mkdocs-changemaker
- Mini QR:
icon: mdi-qrcode
href: "https://qr.{{ cml_domain }}"
description: QR code generator
container: mini-qr
- Excalidraw:
icon: mdi-draw
href: "https://draw.{{ cml_domain }}"
description: Collaborative whiteboard
container: excalidraw-changemaker
- Vaultwarden:
icon: mdi-lock
href: "https://vault.{{ cml_domain }}"
description: Password manager (Bitwarden-compatible)
container: vaultwarden-changemaker
- Gancio:
icon: mdi-calendar-multiple
href: "https://events.{{ cml_domain }}"
description: Event management platform
container: gancio-changemaker
- Production - Integrations:
- Listmonk:
icon: mdi-email-newsletter
href: "https://listmonk.{{ cml_domain }}"
description: Newsletter and mailing list manager
container: listmonk-app
- MailHog:
icon: mdi-email-check
href: "https://mail.{{ cml_domain }}"
description: Email capture for testing
container: mailhog-changemaker
- n8n:
icon: mdi-robot-industrial
href: "https://n8n.{{ cml_domain }}"
description: Workflow automation platform
container: n8n-changemaker
- Gitea:
icon: mdi-git
href: "https://git.{{ cml_domain }}"
description: Git repository hosting
container: gitea-changemaker
- Production - Monitoring:
- Grafana:
icon: mdi-chart-box
href: "https://grafana.{{ cml_domain }}"
description: Monitoring dashboards
container: grafana-changemaker
- Prometheus:
icon: mdi-chart-line
href: "https://prometheus.{{ cml_domain }}"
description: Metrics collection
container: prometheus-changemaker
- Local - Core:
- Admin GUI:
icon: mdi-view-dashboard
href: "http://localhost:{{ cml_admin_port }}"
description: Application dashboard (port {{ cml_admin_port }})
container: changemaker-v2-admin
- API:
icon: mdi-api
href: "http://localhost:{{ cml_api_port }}"
description: V2 REST API (port {{ cml_api_port }})
container: changemaker-v2-api

View File

@ -0,0 +1,16 @@
---
# Common role defaults
# Firewall
ufw_allowed_ports:
- { port: "{{ ssh_port | default(22) }}", proto: tcp, comment: "SSH" }
- { port: 80, proto: tcp, comment: "HTTP" }
- { port: 443, proto: tcp, comment: "HTTPS" }
# fail2ban
fail2ban_maxretry: 5
fail2ban_bantime: 3600
fail2ban_findtime: 600
# Swap (create if < 2GB RAM)
swap_size_mb: 2048

View File

@ -0,0 +1,14 @@
---
- name: Restart fail2ban
ansible.builtin.service:
name: fail2ban
state: restarted
- name: Reload UFW
community.general.ufw:
state: reloaded
- name: Restart Docker
ansible.builtin.service:
name: docker
state: restarted

View File

@ -0,0 +1,42 @@
---
# Install Docker CE + Compose plugin
- name: Install Docker prerequisites
ansible.builtin.apt:
name:
- ca-certificates
- gnupg
- lsb-release
state: present
- name: Add Docker GPG key
ansible.builtin.apt_key:
url: https://download.docker.com/linux/ubuntu/gpg
state: present
- name: Add Docker repository
ansible.builtin.apt_repository:
repo: "deb [arch=amd64] https://download.docker.com/linux/ubuntu {{ ansible_distribution_release }} stable"
state: present
- name: Install Docker CE
ansible.builtin.apt:
name:
- docker-ce
- docker-ce-cli
- containerd.io
- docker-compose-plugin
state: present
update_cache: true
- name: Ensure Docker service is running
ansible.builtin.service:
name: docker
state: started
enabled: true
- name: Add deploy user to docker group
ansible.builtin.user:
name: "{{ ansible_user }}"
groups: docker
append: true

View File

@ -0,0 +1,30 @@
---
# Configure fail2ban for SSH brute-force protection
- name: Ensure fail2ban is installed
ansible.builtin.apt:
name: fail2ban
state: present
- name: Configure fail2ban jail
ansible.builtin.copy:
dest: /etc/fail2ban/jail.local
content: |
[DEFAULT]
bantime = {{ fail2ban_bantime }}
findtime = {{ fail2ban_findtime }}
maxretry = {{ fail2ban_maxretry }}
[sshd]
enabled = true
port = {{ ssh_port | default(22) }}
filter = sshd
logpath = /var/log/auth.log
mode: "0644"
notify: Restart fail2ban
- name: Ensure fail2ban is running
ansible.builtin.service:
name: fail2ban
state: started
enabled: true

View File

@ -0,0 +1,29 @@
---
# Common role — OS setup, Docker, firewall, fail2ban
- name: Update apt cache
ansible.builtin.apt:
update_cache: true
cache_valid_time: 3600
- name: Install base packages
ansible.builtin.apt:
name: "{{ common_packages }}"
state: present
- name: Set timezone to UTC
community.general.timezone:
name: UTC
- name: Configure swap (if needed)
ansible.builtin.include_tasks: swap.yml
when: ansible_memtotal_mb < 3072
- name: Install Docker
ansible.builtin.include_tasks: docker.yml
- name: Configure UFW firewall
ansible.builtin.include_tasks: ufw.yml
- name: Configure fail2ban
ansible.builtin.include_tasks: fail2ban.yml

View File

@ -0,0 +1,36 @@
---
# Create swap file on low-memory servers
- name: Check if swap file exists
ansible.builtin.stat:
path: /swapfile
register: swap_check
- name: Create swap file
when: not swap_check.stat.exists
block:
- name: Allocate swap file
ansible.builtin.command:
cmd: "dd if=/dev/zero of=/swapfile bs=1M count={{ swap_size_mb }}"
changed_when: true
- name: Set swap file permissions
ansible.builtin.file:
path: /swapfile
mode: "0600"
- name: Format swap file
ansible.builtin.command:
cmd: mkswap /swapfile
changed_when: true
- name: Enable swap file
ansible.builtin.command:
cmd: swapon /swapfile
changed_when: true
- name: Add swap to fstab
ansible.builtin.lineinfile:
path: /etc/fstab
line: "/swapfile none swap sw 0 0"
state: present

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