Tonne of updates
This commit is contained in:
parent
56e262ad8b
commit
1a1f12c45b
60
.env.example
60
.env.example
@ -69,7 +69,8 @@ TEST_EMAIL_RECIPIENT=admin@cmlite.org
|
||||
|
||||
# --- Listmonk ---
|
||||
LISTMONK_PORT=9001
|
||||
LISTMONK_DB_PORT=5432
|
||||
# Use 5434 to avoid conflict with main PostgreSQL (5432 internal / 5433 host)
|
||||
LISTMONK_DB_PORT=5434
|
||||
LISTMONK_DB_USER=listmonk
|
||||
LISTMONK_DB_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS
|
||||
LISTMONK_DB_NAME=listmonk
|
||||
@ -83,6 +84,7 @@ LISTMONK_API_TOKEN=GENERATE_WITH_openssl_rand_hex_16
|
||||
LISTMONK_ADMIN_USER=v2-api
|
||||
LISTMONK_ADMIN_PASSWORD=SAME_AS_LISTMONK_API_TOKEN
|
||||
LISTMONK_SYNC_ENABLED=false
|
||||
LISTMONK_WEBHOOK_SECRET=
|
||||
LISTMONK_PROXY_PORT=9002
|
||||
# Listmonk SMTP — MailHog for development (production SMTP added as second provider if credentials set)
|
||||
LISTMONK_SMTP_HOST=mailhog-changemaker
|
||||
@ -124,9 +126,12 @@ ENABLE_PAYMENTS=false
|
||||
ENABLE_MEDIA_FEATURES=false
|
||||
MEDIA_API_PORT=4100
|
||||
MEDIA_API_PUBLIC_URL=http://media-api:4100
|
||||
# Used during admin Docker build to set the media API endpoint for Vite
|
||||
VITE_MEDIA_API_URL=http://changemaker-media-api:4100
|
||||
MEDIA_ROOT=/media/library
|
||||
MEDIA_UPLOADS=/media/uploads
|
||||
MAX_UPLOAD_SIZE_GB=10
|
||||
PUBLIC_MEDIA_PORT=3100
|
||||
VIDEO_PLAYER_DEBUG=false
|
||||
|
||||
# Video Analytics (Feb 2026)
|
||||
@ -168,7 +173,7 @@ GENERIC_TIMEZONE=UTC
|
||||
# This also controls the Vite dev proxy in local development
|
||||
# Change this port to use a different local port, and the admin dev server will automatically use it
|
||||
MKDOCS_PORT=4003
|
||||
MKDOCS_SITE_SERVER_PORT=4001
|
||||
MKDOCS_SITE_SERVER_PORT=4004
|
||||
BASE_DOMAIN=https://cmlite.org
|
||||
MKDOCS_PREVIEW_URL=http://mkdocs:8000
|
||||
MKDOCS_DOCS_PATH=/mkdocs/docs
|
||||
@ -194,6 +199,21 @@ EXCALIDRAW_URL=http://excalidraw-changemaker:80
|
||||
EXCALIDRAW_EMBED_PORT=8886
|
||||
EXCALIDRAW_WS_URL=wss://draw.cmlite.org
|
||||
|
||||
# --- Vaultwarden (Password Manager) ---
|
||||
VAULTWARDEN_PORT=8445
|
||||
VAULTWARDEN_URL=http://vaultwarden-changemaker:80
|
||||
VAULTWARDEN_EMBED_PORT=8890
|
||||
# Admin panel token (access at /admin) — generate with: openssl rand -hex 32
|
||||
VAULTWARDEN_ADMIN_TOKEN=
|
||||
# MUST use HTTPS — Bitwarden web vault enforces HTTPS for account creation
|
||||
# Set to your Pangolin tunnel URL (e.g., https://vault.yourdomain.org)
|
||||
# Local access (browsing existing vault) works on HTTP, but signup/invite requires HTTPS
|
||||
VAULTWARDEN_DOMAIN=https://vault.cmlite.org
|
||||
VAULTWARDEN_SIGNUPS_ALLOWED=false
|
||||
VAULTWARDEN_WEBSOCKET_ENABLED=true
|
||||
# SMTP security: "off" for MailHog, "starttls" or "force_tls" for production
|
||||
VAULTWARDEN_SMTP_SECURITY=off
|
||||
|
||||
# --- MailHog ---
|
||||
MAILHOG_SMTP_PORT=1025
|
||||
MAILHOG_WEB_PORT=8025
|
||||
@ -244,15 +264,45 @@ PANGOLIN_ENDPOINT=https://pangolin.bnkserve.org
|
||||
PANGOLIN_NEWT_ID=
|
||||
PANGOLIN_NEWT_SECRET=
|
||||
|
||||
# --- Prisma CLI (host-side only, NOT used by Docker containers) ---
|
||||
# Containers resolve the DB hostname internally via docker-compose environment
|
||||
# This is used when running `npx prisma migrate dev` from the host machine
|
||||
DATABASE_URL=postgresql://changemaker:YOUR_POSTGRES_PASSWORD@localhost:5433/changemaker_v2
|
||||
|
||||
# --- Rocket.Chat (Team Chat) ---
|
||||
# ENABLE_CHAT is the initial default; once saved in admin Settings, the DB value is authoritative
|
||||
ENABLE_CHAT=false
|
||||
ROCKETCHAT_ADMIN_USER=rcadmin
|
||||
ROCKETCHAT_ADMIN_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS
|
||||
ROCKETCHAT_URL=http://rocketchat-changemaker:3000
|
||||
ROCKETCHAT_EMBED_PORT=8891
|
||||
|
||||
# --- Gancio (Event Management) ---
|
||||
# Uses shared PostgreSQL (database: gancio, auto-created by init-gancio-db.sh)
|
||||
GANCIO_PORT=8092
|
||||
GANCIO_URL=http://gancio-changemaker:13120
|
||||
GANCIO_EMBED_PORT=8892
|
||||
GANCIO_BASE_URL=https://events.cmlite.org
|
||||
# Gancio admin credentials for shift-to-event sync (OAuth login)
|
||||
GANCIO_ADMIN_USER=admin
|
||||
GANCIO_ADMIN_PASSWORD=REQUIRED_STRONG_PASSWORD_CHANGE_THIS
|
||||
# Enable automatic shift → Gancio event sync
|
||||
GANCIO_SYNC_ENABLED=false
|
||||
|
||||
# --- Monitoring (only used with --profile monitoring) ---
|
||||
PROMETHEUS_PORT=9090
|
||||
GRAFANA_PORT=3001
|
||||
GRAFANA_PORT=3005
|
||||
GRAFANA_ADMIN_PASSWORD=admin
|
||||
GRAFANA_ROOT_URL=http://localhost:3001
|
||||
CADVISOR_PORT=8080
|
||||
GRAFANA_ROOT_URL=http://localhost:3005
|
||||
CADVISOR_PORT=8086
|
||||
NODE_EXPORTER_PORT=9100
|
||||
REDIS_EXPORTER_PORT=9121
|
||||
ALERTMANAGER_PORT=9093
|
||||
GOTIFY_PORT=8889
|
||||
GOTIFY_ADMIN_USER=admin
|
||||
GOTIFY_ADMIN_PASSWORD=admin
|
||||
|
||||
# --- Bunker Ops (Fleet Management) ---
|
||||
INSTANCE_LABEL= # Unique label for this instance (defaults to DOMAIN)
|
||||
BUNKER_OPS_ENABLED=false # Enable remote metrics push to central server
|
||||
BUNKER_OPS_REMOTE_WRITE_URL= # VictoriaMetrics remote_write endpoint (e.g., https://ops.example.com/api/v1/write)
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@ -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
257
FEDERATION_PLAN.md
Normal 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
|
||||
@ -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={
|
||||
|
||||
@ -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' },
|
||||
]},
|
||||
],
|
||||
|
||||
@ -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>`;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
123
admin/src/components/canvass/CutCampaignAnalyticsCard.tsx
Normal file
123
admin/src/components/canvass/CutCampaignAnalyticsCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
297
admin/src/components/canvass/ExportContactsModal.tsx
Normal file
297
admin/src/components/canvass/ExportContactsModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
145
admin/src/components/dashboard/ActivityFeedCard.tsx
Normal file
145
admin/src/components/dashboard/ActivityFeedCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
120
admin/src/components/dashboard/ChatNotifierCard.tsx
Normal file
120
admin/src/components/dashboard/ChatNotifierCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
108
admin/src/components/dashboard/TodayEventsCard.tsx
Normal file
108
admin/src/components/dashboard/TodayEventsCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
127
admin/src/pages/GancioPage.tsx
Normal file
127
admin/src/pages/GancioPage.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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'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={[
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
208
admin/src/pages/RocketChatPage.tsx
Normal file
208
admin/src/pages/RocketChatPage.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
),
|
||||
},
|
||||
|
||||
160
admin/src/pages/VaultwardenPage.tsx
Normal file
160
admin/src/pages/VaultwardenPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
125
admin/src/pages/volunteer/VolunteerChatPage.tsx
Normal file
125
admin/src/pages/volunteer/VolunteerChatPage.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -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
22
api/prisma/init-gancio-db.sh
Executable 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"
|
||||
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "site_settings" ADD COLUMN IF NOT EXISTS "notifyAdminShiftCancellation" BOOLEAN NOT NULL DEFAULT true;
|
||||
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "shifts" ADD COLUMN "gancioEventId" INTEGER;
|
||||
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "site_settings" ADD COLUMN IF NOT EXISTS "enable_events" BOOLEAN NOT NULL DEFAULT false;
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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('')
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
69
api/src/modules/listmonk/listmonk-webhook.routes.ts
Normal file
69
api/src/modules/listmonk/listmonk-webhook.routes.ts
Normal 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 };
|
||||
@ -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',
|
||||
|
||||
57
api/src/modules/map/canvass/canvass-export.routes.ts
Normal file
57
api/src/modules/map/canvass/canvass-export.routes.ts
Normal 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 };
|
||||
21
api/src/modules/map/canvass/canvass-export.schemas.ts
Normal file
21
api/src/modules/map/canvass/canvass-export.schemas.ts
Normal 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>;
|
||||
392
api/src/modules/map/canvass/canvass-export.service.ts
Normal file
392
api/src/modules/map/canvass/canvass-export.service.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
},
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
81
api/src/modules/rocketchat/rocketchat.routes.ts
Normal file
81
api/src/modules/rocketchat/rocketchat.routes.ts
Normal 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;
|
||||
129
api/src/modules/rocketchat/rocketchat.service.ts
Normal file
129
api/src/modules/rocketchat/rocketchat.service.ts
Normal 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();
|
||||
@ -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',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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}`);
|
||||
|
||||
@ -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;
|
||||
|
||||
246
api/src/services/gancio.client.ts
Normal file
246
api/src/services/gancio.client.ts
Normal 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();
|
||||
153
api/src/services/listmonk-event-sync.service.ts
Normal file
153
api/src/services/listmonk-event-sync.service.ts
Normal 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();
|
||||
@ -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: [] };
|
||||
|
||||
228
api/src/services/notification-queue.service.ts
Normal file
228
api/src/services/notification-queue.service.ts
Normal 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();
|
||||
33
api/src/services/notification.helper.ts
Normal file
33
api/src/services/notification.helper.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
78
api/src/services/rocketchat-webhook.service.ts
Normal file
78
api/src/services/rocketchat-webhook.service.ts
Normal 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();
|
||||
331
api/src/services/rocketchat.client.ts
Normal file
331
api/src/services/rocketchat.client.ts
Normal 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();
|
||||
112
api/src/templates/email/admin-response-submitted-alert.html
Normal file
112
api/src/templates/email/admin-response-submitted-alert.html
Normal 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>
|
||||
12
api/src/templates/email/admin-response-submitted-alert.txt
Normal file
12
api/src/templates/email/admin-response-submitted-alert.txt
Normal 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}}
|
||||
115
api/src/templates/email/admin-shift-cancellation-alert.html
Normal file
115
api/src/templates/email/admin-shift-cancellation-alert.html
Normal 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>
|
||||
13
api/src/templates/email/admin-shift-cancellation-alert.txt
Normal file
13
api/src/templates/email/admin-shift-cancellation-alert.txt
Normal 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}}
|
||||
115
api/src/templates/email/admin-shift-signup-alert.html
Normal file
115
api/src/templates/email/admin-shift-signup-alert.html
Normal 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>
|
||||
13
api/src/templates/email/admin-shift-signup-alert.txt
Normal file
13
api/src/templates/email/admin-shift-signup-alert.txt
Normal 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}}
|
||||
112
api/src/templates/email/admin-sign-requested-alert.html
Normal file
112
api/src/templates/email/admin-sign-requested-alert.html
Normal 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>
|
||||
12
api/src/templates/email/admin-sign-requested-alert.txt
Normal file
12
api/src/templates/email/admin-sign-requested-alert.txt
Normal 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}}
|
||||
112
api/src/templates/email/volunteer-cancellation-ack.html
Normal file
112
api/src/templates/email/volunteer-cancellation-ack.html
Normal 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>
|
||||
13
api/src/templates/email/volunteer-cancellation-ack.txt
Normal file
13
api/src/templates/email/volunteer-cancellation-ack.txt
Normal 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}}
|
||||
145
api/src/templates/email/volunteer-session-summary.html
Normal file
145
api/src/templates/email/volunteer-session-summary.html
Normal 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>
|
||||
13
api/src/templates/email/volunteer-session-summary.txt
Normal file
13
api/src/templates/email/volunteer-session-summary.txt
Normal 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}}!
|
||||
@ -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
13
bunker-ops/.gitignore
vendored
Normal 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
519
bunker-ops/HOWTO.md
Normal 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
543
bunker-ops/ROLLOUT_PLAN.md
Normal 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
18
bunker-ops/ansible.cfg
Normal 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
|
||||
29
bunker-ops/inventory/group_vars/all/main.yml
Normal file
29
bunker-ops/inventory/group_vars/all/main.yml
Normal 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
|
||||
@ -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: ""
|
||||
28
bunker-ops/inventory/host_vars/example-instance/main.yml
Normal file
28
bunker-ops/inventory/host_vars/example-instance/main.yml
Normal 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
|
||||
63
bunker-ops/inventory/host_vars/example-instance/vault.yml
Normal file
63
bunker-ops/inventory/host_vars/example-instance/vault.yml
Normal 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: ""
|
||||
26
bunker-ops/inventory/hosts.yml
Normal file
26
bunker-ops/inventory/hosts.yml
Normal 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
|
||||
19
bunker-ops/playbooks/backup.yml
Normal file
19
bunker-ops/playbooks/backup.yml
Normal 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 }}"
|
||||
36
bunker-ops/playbooks/configure.yml
Normal file
36
bunker-ops/playbooks/configure.yml
Normal 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' }}
|
||||
43
bunker-ops/playbooks/deploy.yml
Normal file
43
bunker-ops/playbooks/deploy.yml
Normal 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' }}
|
||||
18
bunker-ops/playbooks/monitoring.yml
Normal file
18
bunker-ops/playbooks/monitoring.yml
Normal 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' }}
|
||||
78
bunker-ops/playbooks/upgrade.yml
Normal file
78
bunker-ops/playbooks/upgrade.yml
Normal 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
|
||||
53
bunker-ops/roles/changemaker/defaults/main.yml
Normal file
53
bunker-ops/roles/changemaker/defaults/main.yml
Normal 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
|
||||
18
bunker-ops/roles/changemaker/handlers/main.yml
Normal file
18
bunker-ops/roles/changemaker/handlers/main.yml
Normal 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
|
||||
15
bunker-ops/roles/changemaker/tasks/backup.yml
Normal file
15
bunker-ops/roles/changemaker/tasks/backup.yml
Normal 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 }}"
|
||||
21
bunker-ops/roles/changemaker/tasks/clone.yml
Normal file
21
bunker-ops/roles/changemaker/tasks/clone.yml
Normal 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') }}"
|
||||
54
bunker-ops/roles/changemaker/tasks/deploy.yml
Normal file
54
bunker-ops/roles/changemaker/tasks/deploy.yml
Normal 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
|
||||
20
bunker-ops/roles/changemaker/tasks/dirs.yml
Normal file
20
bunker-ops/roles/changemaker/tasks/dirs.yml
Normal 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
|
||||
14
bunker-ops/roles/changemaker/tasks/env.yml
Normal file
14
bunker-ops/roles/changemaker/tasks/env.yml
Normal 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' }}"
|
||||
38
bunker-ops/roles/changemaker/tasks/health.yml
Normal file
38
bunker-ops/roles/changemaker/tasks/health.yml
Normal 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' }}
|
||||
30
bunker-ops/roles/changemaker/tasks/main.yml
Normal file
30
bunker-ops/roles/changemaker/tasks/main.yml
Normal 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]
|
||||
14
bunker-ops/roles/changemaker/tasks/services.yml
Normal file
14
bunker-ops/roles/changemaker/tasks/services.yml
Normal 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"
|
||||
195
bunker-ops/roles/changemaker/templates/env.j2
Normal file
195
bunker-ops/roles/changemaker/templates/env.j2
Normal 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 }}
|
||||
127
bunker-ops/roles/changemaker/templates/services.yaml.j2
Normal file
127
bunker-ops/roles/changemaker/templates/services.yaml.j2
Normal 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
|
||||
16
bunker-ops/roles/common/defaults/main.yml
Normal file
16
bunker-ops/roles/common/defaults/main.yml
Normal 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
|
||||
14
bunker-ops/roles/common/handlers/main.yml
Normal file
14
bunker-ops/roles/common/handlers/main.yml
Normal 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
|
||||
42
bunker-ops/roles/common/tasks/docker.yml
Normal file
42
bunker-ops/roles/common/tasks/docker.yml
Normal 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
|
||||
30
bunker-ops/roles/common/tasks/fail2ban.yml
Normal file
30
bunker-ops/roles/common/tasks/fail2ban.yml
Normal 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
|
||||
29
bunker-ops/roles/common/tasks/main.yml
Normal file
29
bunker-ops/roles/common/tasks/main.yml
Normal 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
|
||||
36
bunker-ops/roles/common/tasks/swap.yml
Normal file
36
bunker-ops/roles/common/tasks/swap.yml
Normal 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
Loading…
x
Reference in New Issue
Block a user