Compare commits

..

7 Commits

Author SHA1 Message Date
91db29402c Add Gitea SSO, fix security audit findings, harden production defaults
Gitea SSO: cookie-based single sign-on via nginx auth_request — sets
cml_session cookie on login/refresh, validates via /api/auth/gitea-sso-validate,
injects X-WEBAUTH-USER header for reverse proxy auth. Dedicated GITEA_SSO_SECRET
and SERVICE_PASSWORD_SALT env vars isolate secret rotation.

Security fixes from March 30 audit: IDOR on ticketed events (requireEventOwnership
middleware), IDOR on action items (admin/assignee/creator check), path traversal
on photos (resolve-based validation), CSV upload size limit (5MB), shared calendar
email exposure removed.

Gitea provisioner: auto-sync docs repo collaborator access based on role
(CONTENT_ROLES get write, SUPER_ADMIN gets admin). Gitea client extended
with collaborator management API methods.

Production hardening: NODE_ENV defaults to production in docker-compose.prod.yml,
Grafana anonymous auth disabled, install.sh branch ref updated to main.

Admin UI: moved docs reset from toolbar to MkDocs Settings danger zone,
improved collab Ctrl+S to explicitly save + cache-bust preview.

MkDocs site rebuild with updated repo data, upgrade screenshots, and content.

Bunker Admin
2026-03-31 11:20:01 -06:00
9321aeb263 Move SMS phone bridge from campaign_connector submodule into main repo
Consolidates the Termux SMS server code (previously in a separate
campaign_connector git submodule) into termux-sms/ at repo root.
Updates phone clone commands to use sparse checkout so only the
termux-sms/ directory is downloaded onto the Android device.

Bunker Admin
2026-03-31 11:04:14 -06:00
5d15b4cffa Add engagement scoring and homepage stats EventBus listeners
- Engagement scoring listener: 11 event subscriptions, weighted scoring
  (donation=50, subscription=40, shift=20, canvass=15, email=10, video=3),
  Redis sorted set leaderboard, per-contact score + last-activity tracking
- Homepage stats listener: 12 subscriptions, incremental Redis counters
  (emails, signups, donations, responses, canvass, videos), capped recent
  activity lists (last 20 per type), cache invalidation on data changes
- GET /api/homepage/live-stats — public real-time counters + recent activity
- GET /api/observability/engagement-leaderboard — admin top-N contacts
- Total: 8 listeners, 70 subscriptions across all modules

Bunker Admin
2026-03-31 10:21:05 -06:00
902adce646 Add Straw Polls feature: quick opinion polling with public landers, MkDocs widgets, and social integration
Full-stack implementation across 7 sprints:
- Backend: 5 Prisma models (StrawPoll, Option, Vote, Comment, Challenge), 4 enums, POLLS_ADMIN role,
  admin CRUD routes, public voting/SSE/widget endpoints, BullMQ auto-close queue, rate limiting
- Admin: StrawPollsPage with inline drawers (campaigns pattern), PollResults bar chart, sidebar under Advocacy
- Public: dedicated poll lander with real-time SSE updates, browse page, anonymous voting with token dedup
- MkDocs: straw-poll-widget.js hydration (inline vote + card link modes), GrapesJS block types
- Social: feed activity (poll_voted), friend badge integration, challenge notifications, notification preferences
- Feature flag: enablePolls toggle in Settings, FeatureGate, Zod schema

Bunker Admin
2026-03-31 10:16:56 -06:00
68434c51a6 Extend EventBus: RC notifications, CRM activity, Gancio migration, calendar source types
- Add 7 new RC notification types: campaign published, donations, subscriptions,
  SMS escalations, user approved, video published, ticketed events
- Add CRM activity entries for subscription activated and email bounced
- Migrate ticketed-events Gancio sync from inline calls to EventBus listener
- Add meeting.created/deleted events from jitsi.routes.ts
- Add SHIFT, MEETING, TICKETED_EVENT to CalendarItemSource enum (Prisma migration)
- Update calendar-sync listener to use proper source types instead of MANUAL
- Total: 45 listener subscriptions across 6 modules, zero inline sync calls remaining

Bunker Admin
2026-03-31 10:04:44 -06:00
075a7c8c4a Redesign hero section: two-column layout, showcase cards, animations
- Two-column desktop layout (left: text/CTAs, right: feature showcase)
- Typewriter rotating words animation cycling through 8 platform capabilities
- Feature showcase with 4 auto-rotating screenshot cards (campaigns, canvassing, media, shifts)
- Staggered feature pill badges linking to corresponding sections below
- Terminal quick-deploy snippet with copy-to-clipboard
- Canvas particle drift background animation
- Count-up stats with IntersectionObserver
- Real screenshots replace mock data in showcase cards
- Light/dark theme support for all new elements
- Mobile responsive: single-column stack, overflow containment, scaled typography
- prefers-reduced-motion respected across all animations

Bunker Admin
2026-03-31 10:01:48 -06:00
0c2ffe754e Harden Stripe payment integration: 15 security fixes from audit
Addresses 11 original findings (1 critical, 3 high, 4 medium, 3 low)
plus 4 additional findings from security review:

- Mask secrets in PUT /settings response (was leaking decrypted keys)
- Add paymentCheckoutRateLimit (10/hr/IP) to all 5 checkout endpoints
- Implement durable audit logging to payment_audit_log table
- Pin Stripe API version to 2026-01-28.clover (SDK v20.3.1)
- Add charge.dispute.created/closed webhook handlers with DISPUTED status
- Restore tickets on dispute won, handle charge_refunded closure
- Guard against sentinel passthrough corrupting stored Stripe keys
- Wrap refund DB updates in try/catch with webhook reconciliation fallback
- Add $transaction for product maxPurchases race condition
- Remove dead Payment model lookup from handleChargeRefunded
- Cap donation amount at $100k in both schemas
- Add requirePaymentsEnabled middleware on all checkout routes
- Remove Stripe internal IDs from CSV exports
- Add Cache-Control: no-store on admin settings responses

Bunker Admin
2026-03-31 08:34:23 -06:00
277 changed files with 18127 additions and 1534 deletions

View File

@ -53,6 +53,14 @@ JWT_REFRESH_EXPIRY=7d
# Generate with: openssl rand -hex 32 # Generate with: openssl rand -hex 32
ENCRYPTION_KEY=GENERATE_WITH_openssl_rand_hex_32 ENCRYPTION_KEY=GENERATE_WITH_openssl_rand_hex_32
# Gitea SSO cookie signing secret (separate from JWT — falls back to JWT_ACCESS_SECRET if empty)
# Generate with: openssl rand -hex 32
GITEA_SSO_SECRET=
# Salt for deriving deterministic service passwords (Gitea, Rocket.Chat)
# Falls back to JWT_ACCESS_SECRET if empty — set a dedicated value to isolate secret rotation
# Generate with: openssl rand -hex 32
SERVICE_PASSWORD_SALT=
# --- Initial Super Admin User (auto-created during database seeding) --- # --- Initial Super Admin User (auto-created during database seeding) ---
# These credentials are used to create the initial super admin account # These credentials are used to create the initial super admin account
# Change these before running the seed script in production # Change these before running the seed script in production

View File

@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
Changemaker Lite is a self-hosted political campaign platform built with Docker Compose. It consolidates advocacy email campaigns, geographic mapping, volunteer management, and administration into a single TypeScript stack. The primary domain is `cmlite.org`. Changemaker Lite is a self-hosted political campaign platform built with Docker Compose. It consolidates advocacy email campaigns, geographic mapping, volunteer management, and administration into a single TypeScript stack. The primary domain is `cmlite.org`.
**Current state:** V2 rebuild substantially complete on the `v2` branch. Core platform operational with Phases 1-14 complete. See `V2_PLAN.md` for the full roadmap. **Current state:** V2 rebuild substantially complete (merged to `main`). Core platform operational with Phases 1-14 complete. See `V2_PLAN.md` for the full roadmap.
**Status Summary:** **Status Summary:**
- ✅ Phases 1-14 Complete (Foundation through Monitoring + DevOps) - ✅ Phases 1-14 Complete (Foundation through Monitoring + DevOps)
@ -160,7 +160,7 @@ changemaker.lite/
The fastest way to deploy. No source code, no compilation: The fastest way to deploy. No source code, no compilation:
```bash ```bash
curl -fsSL https://gitea.bnkops.com/admin/changemaker.lite/raw/branch/v2/scripts/install.sh | bash curl -fsSL https://gitea.bnkops.com/admin/changemaker.lite/raw/branch/main/scripts/install.sh | bash
``` ```
This downloads a ~9MB release tarball, runs the config wizard, and sets `IMAGE_TAG=latest`. Then: This downloads a ~9MB release tarball, runs the config wizard, and sets `IMAGE_TAG=latest`. Then:
@ -173,11 +173,10 @@ Pre-built images are pulled from `gitea.bnkops.com/admin` (~2 min). Database mig
### Source Install (Development) ### Source Install (Development)
1. **Clone repository and checkout v2 branch:** 1. **Clone repository:**
```bash ```bash
git clone <repo-url> changemaker.lite git clone <repo-url> changemaker.lite
cd changemaker.lite cd changemaker.lite
git checkout v2
``` ```
2. **Create environment file:** 2. **Create environment file:**
@ -321,7 +320,7 @@ docker compose down
./scripts/upgrade.sh --use-registry --force --skip-backup ./scripts/upgrade.sh --use-registry --force --skip-backup
# Install from tarball (end-user one-liner) # Install from tarball (end-user one-liner)
curl -fsSL https://gitea.bnkops.com/admin/changemaker.lite/raw/branch/v2/scripts/install.sh | bash curl -fsSL https://gitea.bnkops.com/admin/changemaker.lite/raw/branch/main/scripts/install.sh | bash
``` ```
**Two compose files:** **Two compose files:**

View File

@ -174,7 +174,7 @@ The tarball contains:
```bash ```bash
# One-liner # One-liner
curl -fsSL https://gitea.bnkops.com/admin/changemaker.lite/raw/branch/v2/scripts/install.sh | bash curl -fsSL https://gitea.bnkops.com/admin/changemaker.lite/raw/branch/main/scripts/install.sh | bash
# Or manual # Or manual
curl -LO https://gitea.bnkops.com/admin/changemaker.lite/releases/latest/download/changemaker-lite-latest.tar.gz curl -LO https://gitea.bnkops.com/admin/changemaker.lite/releases/latest/download/changemaker-lite-latest.tar.gz

View File

@ -105,7 +105,7 @@ Send SMS campaigns via an Android bridge, sync subscribers to Listmonk for newsl
```bash ```bash
# One-command install (downloads pre-built images, runs config wizard) # One-command install (downloads pre-built images, runs config wizard)
curl -fsSL https://gitea.bnkops.com/admin/changemaker.lite/raw/branch/v2/scripts/install.sh | bash curl -fsSL https://gitea.bnkops.com/admin/changemaker.lite/raw/branch/main/scripts/install.sh | bash
cd ~/changemaker.lite cd ~/changemaker.lite
docker compose up -d docker compose up -d
@ -115,7 +115,7 @@ Or clone and build from source:
```bash ```bash
git clone <repo-url> changemaker.lite git clone <repo-url> changemaker.lite
cd changemaker.lite && git checkout v2 cd changemaker.lite
cp .env.example .env cp .env.example .env
# Edit .env -- set passwords, JWT secrets, admin credentials # Edit .env -- set passwords, JWT secrets, admin credentials

161
SERVICE_INTEGRATIONS.md Normal file
View File

@ -0,0 +1,161 @@
# Service Integrations — EventBus Architecture
Tracking document for the platform-wide EventBus and service integration work.
**Started:** 2026-03-30
**Branch:** v2
---
## Architecture Overview
Changemaker Lite has 30+ services but most operate as isolated tools. The EventBus provides a centralized, typed, in-process pub/sub system that decouples event producers from consumers.
```
Service Handler (shift created, donation completed, etc.)
|
v
eventBus.publish('shift.created', payload)
|
+-- ListmonkListener (newsletter sync)
+-- RocketChatListener (team notifications)
+-- CrmActivityListener (contact timeline)
+-- CalendarSyncListener (unified calendar)
+-- N8nWebhookListener (external automation)
+-- GancioSyncListener (public event calendar)
```
### Why In-Process EventEmitter (not Redis PubSub)
- Single Express process — no distributed coordination needed
- Zero serialization overhead (pass JS objects directly)
- Data already persisted in DB — events are ephemeral notifications
- Matches the existing fire-and-forget pattern used by Listmonk/RC services
- Can be swapped to Redis PubSub later if we go multi-process
### Key Files
| File | Purpose |
|------|---------|
| `api/src/types/events.ts` | Typed event catalog (all event names + payloads) |
| `api/src/services/event-bus.service.ts` | Core EventBus (publish/subscribe/stats) |
| `api/src/services/event-listeners/listmonk.listener.ts` | Listmonk newsletter sync |
| `api/src/services/event-listeners/rocketchat.listener.ts` | Rocket.Chat notifications |
| `api/src/services/event-listeners/crm-activity.listener.ts` | CRM ContactActivity writer |
| `api/src/services/event-listeners/calendar-sync.listener.ts` | Calendar unification |
| `api/src/services/event-listeners/n8n-webhook.listener.ts` | n8n automation bridge |
| `api/src/services/event-listeners/gancio.listener.ts` | Gancio event sync (shifts + ticketed events) |
| `api/src/services/event-listeners/engagement-scoring.listener.ts` | Contact engagement scores (Redis ZSET) |
| `api/src/services/event-listeners/homepage-stats.listener.ts` | Homepage real-time counters + cache invalidation |
---
## Progress Tracker
### Phase 1: Core Infrastructure
- [x] Explore existing event patterns (Listmonk, RC, Gancio, provisioning)
- [x] Design EventBus architecture
- [x] Implement EventBus service (`api/src/services/event-bus.service.ts`)
- [x] Define typed event catalog (`api/src/types/events.ts` — 46 events across 14 modules)
- [x] Register EventBus in server.ts startup
- [x] Add EventBus stats endpoint (`GET /api/observability/event-bus`)
### Phase 2: Migrate Existing Integrations
- [x] Listmonk event sync → EventBus listener (9 event subscriptions)
- [x] Rocket.Chat webhook service → EventBus listener (4 event subscriptions)
- [x] Gancio shift/event sync → EventBus listener (3 event subscriptions)
### Phase 3: New Listeners
- [x] CRM Activity auto-generation listener (11 event subscriptions)
- [x] Calendar sync listener (8 event subscriptions)
- [x] n8n webhook emitter listener (wildcard subscription, forwards all events)
- [x] Listmonk webhook receiver (inbound: open, click, bounce, unsubscribe → EventBus)
### Phase 4: Wire Up Publishers (migrated from inline calls)
- [x] Shift CRUD + signup (shift.created/updated/deleted, shift.signup.created/cancelled)
- [x] Canvass session complete + visits (canvass.session.completed, contact.address.updated)
- [x] Response submit (response.submitted)
- [x] Campaign email sent (campaign.email.sent)
- [x] Payment/donation/subscription events (3 event types)
- [x] Contact tag changes (contact.tags.changed — 3 call sites)
- [x] Reengagement sent (reengagement.sent)
- [x] Campaign CRUD + publish + moderation (campaign.created/updated/deleted/published/status.changed)
- [x] User create/update/delete/approve (user.created/updated/deleted/approved)
- [x] SMS campaign start/complete + message send/receive (4 event types)
- [x] Media video publish/unpublish/view (3 event types)
- [x] Ticketed event publish/cancel (EventBus publishes alongside existing Gancio calls)
- [x] Impact story publish (social.impact-story.published)
- [x] Meeting create/delete (jitsi.routes.ts — meeting.created, meeting.deleted)
### Phase 4b: Extended Listeners (2026-03-31)
- [x] RC listener: +7 subscriptions (campaign.published, donations, subscriptions, SMS escalation, user.approved, video.published, ticketed-event.published)
- [x] CRM listener: +2 subscriptions (subscription activated, email bounced)
- [x] RC webhook service: +7 new formatter methods
- [x] Prisma migration: SHIFT, MEETING, TICKETED_EVENT added to CalendarItemSource enum
- [x] Calendar sync listener: uses proper source types (SHIFT, MEETING, TICKETED_EVENT)
### Phase 4c: New Data Listeners (2026-03-31)
- [x] Engagement scoring listener (11 subscriptions, Redis ZSET leaderboard)
- [x] Homepage stats listener (12 subscriptions, Redis counters + recent activity)
- [x] GET /api/homepage/live-stats endpoint (public, real-time counters + recent)
- [x] GET /api/observability/engagement-leaderboard endpoint (admin, top contacts)
### Phase 5: Future
- [ ] Migrate meeting-planner Gancio calls to EventBus (blocked: synchronous return value needed)
- [ ] Homepage service: swap COUNT queries for Redis counters in getStats()
- [ ] Engagement score materialization: periodic job to denormalize scores to Contact model
---
## Event Catalog
### Currently Wired (11 event points, 3 consumers)
| Event | Listmonk | Rocket.Chat | Gancio |
|-------|----------|-------------|--------|
| shift.signup | yes | yes | - |
| shift.signup.cancelled | - | yes | - |
| shift.created | - | - | yes |
| shift.updated | - | - | yes |
| shift.deleted | - | - | yes |
| canvass.session.completed | yes | yes | - |
| canvass.address.updated | yes | - | - |
| campaign.email.sent | yes | - | - |
| response.submitted | - | yes | - |
| subscription.activated | yes | - | - |
| donation.completed | yes | - | - |
| product.purchased | yes | - | - |
| contact.tags.changed | yes | - | - |
| reengagement.sent | yes | - | - |
### New Events (49+ handlers need publishers)
| Event | CRM Activity | Calendar | RC | n8n |
|-------|-------------|----------|-----|-----|
| campaign.created | - | - | - | yes |
| campaign.published | - | - | yes | yes |
| campaign.status.changed | - | - | yes | yes |
| user.approved | - | - | yes | yes |
| user.created | - | - | - | yes |
| video.published | - | - | yes | yes |
| video.viewed | yes | - | - | - |
| sms.message.received | yes | - | yes* | yes |
| sms.campaign.completed | - | - | yes | yes |
| ticketed-event.published | - | yes | - | yes |
| meeting.created | - | yes | - | - |
| impact-story.published | - | - | yes | yes |
| shift.created | - | yes | - | yes |
| donation.completed | yes | - | yes | yes |
| subscription.activated | yes | - | - | yes |
*SMS escalations (QUESTION/NEGATIVE sentiment) to relevant RC channel
---
## Design Decisions
1. **Listeners self-guard**: Each listener checks its own feature flag (ENABLE_CHAT, LISTMONK_SYNC_ENABLED, etc.) — the EventBus doesn't filter
2. **Error isolation**: Each listener wraps its handler in try-catch; one listener failing doesn't affect others
3. **No persistence**: Events are ephemeral — if the server restarts mid-event, it's lost (data is already in DB)
4. **Stats tracking**: EventBus tracks per-event emission counts + per-listener execution counts for observability
5. **Wildcard subscriptions**: Listeners can subscribe to `shift.*` to catch all shift events

225
admin/package-lock.json generated
View File

@ -1154,9 +1154,9 @@
"dev": true "dev": true
}, },
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
"integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -1167,9 +1167,9 @@
] ]
}, },
"node_modules/@rollup/rollup-android-arm64": { "node_modules/@rollup/rollup-android-arm64": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz",
"integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1180,9 +1180,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-arm64": { "node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz",
"integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1193,9 +1193,9 @@
] ]
}, },
"node_modules/@rollup/rollup-darwin-x64": { "node_modules/@rollup/rollup-darwin-x64": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz",
"integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1206,9 +1206,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-arm64": { "node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz",
"integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1219,9 +1219,9 @@
] ]
}, },
"node_modules/@rollup/rollup-freebsd-x64": { "node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz",
"integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1232,9 +1232,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-gnueabihf": { "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz",
"integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -1245,9 +1245,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm-musleabihf": { "node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz",
"integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@ -1258,9 +1258,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-gnu": { "node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz",
"integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1271,9 +1271,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-arm64-musl": { "node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz",
"integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1284,9 +1284,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-loong64-gnu": { "node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz",
"integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@ -1297,9 +1297,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-loong64-musl": { "node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz",
"integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==",
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
@ -1310,9 +1310,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-ppc64-gnu": { "node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz",
"integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@ -1323,9 +1323,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-ppc64-musl": { "node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz",
"integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==",
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
@ -1336,9 +1336,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-gnu": { "node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz",
"integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@ -1349,9 +1349,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-riscv64-musl": { "node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz",
"integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@ -1362,9 +1362,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-s390x-gnu": { "node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz",
"integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==",
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
@ -1375,9 +1375,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-gnu": { "node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz",
"integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1388,9 +1388,9 @@
] ]
}, },
"node_modules/@rollup/rollup-linux-x64-musl": { "node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz",
"integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1401,9 +1401,9 @@
] ]
}, },
"node_modules/@rollup/rollup-openbsd-x64": { "node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz",
"integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1414,9 +1414,9 @@
] ]
}, },
"node_modules/@rollup/rollup-openharmony-arm64": { "node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz",
"integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1427,9 +1427,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-arm64-msvc": { "node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz",
"integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@ -1440,9 +1440,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-ia32-msvc": { "node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz",
"integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==",
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
@ -1453,9 +1453,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-gnu": { "node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz",
"integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -1466,9 +1466,9 @@
] ]
}, },
"node_modules/@rollup/rollup-win32-x64-msvc": { "node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz",
"integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@ -2261,9 +2261,9 @@
} }
}, },
"node_modules/dompurify": { "node_modules/dompurify": {
"version": "3.3.1", "version": "3.3.3",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
"optionalDependencies": { "optionalDependencies": {
"@types/trusted-types": "^2.0.7" "@types/trusted-types": "^2.0.7"
} }
@ -2860,9 +2860,9 @@
"dev": true "dev": true
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "4.0.3", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true, "dev": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@ -3651,9 +3651,9 @@
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==" "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
}, },
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.57.1", "version": "4.60.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
"integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@types/estree": "1.0.8" "@types/estree": "1.0.8"
@ -3666,31 +3666,31 @@
"npm": ">=8.0.0" "npm": ">=8.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm-eabi": "4.60.1",
"@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-android-arm64": "4.60.1",
"@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.60.1",
"@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-darwin-x64": "4.60.1",
"@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.60.1",
"@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.60.1",
"@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1",
"@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1",
"@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1",
"@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.60.1",
"@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1",
"@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.60.1",
"@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1",
"@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1",
"@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1",
"@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1",
"@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1",
"@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.60.1",
"@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.60.1",
"@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openbsd-x64": "4.60.1",
"@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.60.1",
"@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1",
"@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1",
"@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.60.1",
"@rollup/rollup-win32-x64-msvc": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.60.1",
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
@ -3993,10 +3993,9 @@
"dev": true "dev": true
}, },
"node_modules/yaml": { "node_modules/yaml": {
"version": "2.8.2", "version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"license": "ISC",
"bin": { "bin": {
"yaml": "bin.mjs" "yaml": "bin.mjs"
}, },

View File

@ -112,6 +112,7 @@ import {
EVENTS_ROLES, EVENTS_ROLES,
SOCIAL_ROLES, SOCIAL_ROLES,
SYSTEM_ROLES, SYSTEM_ROLES,
POLLS_ROLES,
} from '@/types/api'; } from '@/types/api';
import { isAdmin } from '@/utils/roles'; import { isAdmin } from '@/utils/roles';
import QuickJoinPage from '@/pages/public/QuickJoinPage'; import QuickJoinPage from '@/pages/public/QuickJoinPage';
@ -132,6 +133,7 @@ import ReferralAdminPage from '@/pages/social/ReferralAdminPage';
import SpotlightAdminPage from '@/pages/social/SpotlightAdminPage'; import SpotlightAdminPage from '@/pages/social/SpotlightAdminPage';
import ChallengesAdminPage from '@/pages/social/ChallengesAdminPage'; import ChallengesAdminPage from '@/pages/social/ChallengesAdminPage';
import ImpactStoriesPage from '@/pages/influence/ImpactStoriesPage'; import ImpactStoriesPage from '@/pages/influence/ImpactStoriesPage';
import StrawPollsPage from '@/pages/influence/StrawPollsPage';
import ReferralsPage from '@/pages/volunteer/ReferralsPage'; import ReferralsPage from '@/pages/volunteer/ReferralsPage';
import ChallengesPage from '@/pages/volunteer/ChallengesPage'; import ChallengesPage from '@/pages/volunteer/ChallengesPage';
import ChallengeDetailPage from '@/pages/volunteer/ChallengeDetailPage'; import ChallengeDetailPage from '@/pages/volunteer/ChallengeDetailPage';
@ -142,6 +144,8 @@ import MeetingAgendaPage from '@/pages/MeetingAgendaPage';
import ActionItemsPage from '@/pages/ActionItemsPage'; import ActionItemsPage from '@/pages/ActionItemsPage';
import SchedulingPollPage from '@/pages/public/SchedulingPollPage'; import SchedulingPollPage from '@/pages/public/SchedulingPollPage';
import PollsListPage from '@/pages/public/PollsListPage'; import PollsListPage from '@/pages/public/PollsListPage';
import StrawPollPage from '@/pages/public/StrawPollPage';
import StrawPollsListPage from '@/pages/public/StrawPollsListPage';
import JitsiAuthPage from '@/pages/JitsiAuthPage'; import JitsiAuthPage from '@/pages/JitsiAuthPage';
import SchedulingCalendarPage from '@/pages/SchedulingCalendarPage'; import SchedulingCalendarPage from '@/pages/SchedulingCalendarPage';
import AdminCalendarViewPage from '@/pages/AdminCalendarViewPage'; import AdminCalendarViewPage from '@/pages/AdminCalendarViewPage';
@ -276,6 +280,14 @@ export default function App() {
<Route index element={<SchedulingPollPage />} /> <Route index element={<SchedulingPollPage />} />
</Route> </Route>
{/* Straw polls — feature-gated */}
<Route path="/straw-polls" element={<FeatureGate feature="enablePolls"><PublicLayout /></FeatureGate>}>
<Route index element={<StrawPollsListPage />} />
</Route>
<Route path="/straw-poll/:slug" element={<FeatureGate feature="enablePolls"><PublicLayout /></FeatureGate>}>
<Route index element={<StrawPollPage />} />
</Route>
{/* Public ticketed event pages — feature-gated */} {/* Public ticketed event pages — feature-gated */}
<Route path="/event/:slug" element={<FeatureGate feature="enableTicketedEvents"><PublicLayout /></FeatureGate>}> <Route path="/event/:slug" element={<FeatureGate feature="enableTicketedEvents"><PublicLayout /></FeatureGate>}>
<Route index element={<TicketedEventDetailPage />} /> <Route index element={<TicketedEventDetailPage />} />
@ -562,6 +574,14 @@ export default function App() {
</ProtectedRoute> </ProtectedRoute>
} }
/> />
<Route
path="influence/straw-polls"
element={
<ProtectedRoute requiredRoles={POLLS_ROLES}>
<StrawPollsPage />
</ProtectedRoute>
}
/>
<Route <Route
path="listmonk" path="listmonk"
element={ element={

View File

@ -71,6 +71,7 @@ import {
MEDIA_ROLES, MEDIA_ROLES,
PAYMENTS_ROLES, PAYMENTS_ROLES,
SOCIAL_ROLES, SOCIAL_ROLES,
POLLS_ROLES,
} from '@/types/api'; } from '@/types/api';
import { buildHomeUrl, resolveNavUrl } from '@/lib/service-url'; import { buildHomeUrl, resolveNavUrl } from '@/lib/service-url';
import type { NavItem } from '@/types/api'; import type { NavItem } from '@/types/api';
@ -187,6 +188,7 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, use
{ key: '/app/responses', icon: <MessageOutlined />, label: badges?.pendingResponses ? <Badge count={badges.pendingResponses} size="small" offset={[8, 0]}>Responses</Badge> : 'Responses' }, { key: '/app/responses', icon: <MessageOutlined />, label: badges?.pendingResponses ? <Badge count={badges.pendingResponses} size="small" offset={[8, 0]}>Responses</Badge> : 'Responses' },
{ key: '/app/influence/effectiveness', icon: <LineChartOutlined />, label: 'Effectiveness' }, { key: '/app/influence/effectiveness', icon: <LineChartOutlined />, label: 'Effectiveness' },
{ key: '/app/influence/stories', icon: <TrophyOutlined />, label: 'Impact Stories' }, { key: '/app/influence/stories', icon: <TrophyOutlined />, label: 'Impact Stories' },
...(settings?.enablePolls !== false && can(POLLS_ROLES) ? [{ key: '/app/influence/straw-polls', icon: <BarChartOutlined />, label: 'Straw Polls' }] : []),
], ],
}); });
} }
@ -712,6 +714,7 @@ export default function AppLayout() {
</Dropdown> </Dropdown>
</Header> </Header>
<Content <Content
id="app-content-area"
style={{ style={{
margin: fullBleed ? 0 : (isMobile ? 12 : 24), margin: fullBleed ? 0 : (isMobile ? 12 : 24),
padding: fullBleed ? 0 : (isMobile ? 16 : 24), padding: fullBleed ? 0 : (isMobile ? 16 : 24),
@ -719,6 +722,7 @@ export default function AppLayout() {
borderRadius: fullBleed ? 0 : token.borderRadiusLG, borderRadius: fullBleed ? 0 : token.borderRadiusLG,
minHeight: 280, minHeight: 280,
overflow: fullBleed ? 'hidden' : undefined, overflow: fullBleed ? 'hidden' : undefined,
position: 'relative',
}} }}
> >
<Outlet context={{ setPageHeader } satisfies AppOutletContext} /> <Outlet context={{ setPageHeader } satisfies AppOutletContext} />

View File

@ -22,10 +22,11 @@ const FEATURE_LABELS: Record<string, string> = {
enableMeetingPlanner: 'Meeting Planner', enableMeetingPlanner: 'Meeting Planner',
enableTicketedEvents: 'Ticketed Events', enableTicketedEvents: 'Ticketed Events',
enableSocialCalendar: 'Social Calendar', enableSocialCalendar: 'Social Calendar',
enablePolls: 'Straw Polls',
}; };
interface FeatureGateProps { interface FeatureGateProps {
feature: keyof Pick<SiteSettings, 'enableInfluence' | 'enableMap' | 'enableLandingPages' | 'enableNewsletter' | 'enableMediaFeatures' | 'enablePayments' | 'enableGalleryAds' | 'enablePeople' | 'enableEvents' | 'enableSocial' | 'enableMeet' | 'enableMeetingPlanner' | 'enableTicketedEvents' | 'enableSocialCalendar'>; feature: keyof Pick<SiteSettings, 'enableInfluence' | 'enableMap' | 'enableLandingPages' | 'enableNewsletter' | 'enableMediaFeatures' | 'enablePayments' | 'enableGalleryAds' | 'enablePeople' | 'enableEvents' | 'enableSocial' | 'enableMeet' | 'enableMeetingPlanner' | 'enableTicketedEvents' | 'enableSocialCalendar' | 'enablePolls'>;
children: ReactNode; children: ReactNode;
} }

View File

@ -571,6 +571,40 @@ function generateBlockHtml(type: string, defaults: Record<string, unknown>): str
</div> </div>
</section>`; </section>`;
} }
case 'straw-poll-inline': {
const pollSlug = (defaults.pollSlug as string) || '';
return `
<section style="padding: 60px 40px;">
<div class="straw-poll-inline"
data-poll-slug="${pollSlug}"
data-show-results="true"
style="max-width: 500px; margin: 0 auto;">
<div style="background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%); border-radius: 12px; padding: 32px; text-align: center; color: #fff;">
<svg style="width: 64px; height: 64px; margin-bottom: 16px; opacity: 0.9;" fill="currentColor" viewBox="0 0 1024 1024">
<path d="M160 960h128V480H160v480zm256 0h128V320H416v640zm256 0h128V160H672v800z"/>
</svg>
<p style="margin: 0; font-size: 1.2rem; font-weight: 600;">Straw Poll (Inline)</p>
<p style="margin: 8px 0 0; font-size: 0.9rem; opacity: 0.85;">${pollSlug || 'Set poll slug in block properties'}</p>
<p style="margin: 12px 0 0; font-size: 0.75rem; opacity: 0.6; font-style: italic;">Inline voting widget renders on published page</p>
</div>
</div>
</section>`;
}
case 'straw-poll-card': {
const pollSlug = (defaults.pollSlug as string) || '';
return `
<section style="padding: 40px;">
<div class="straw-poll-card"
data-poll-slug="${pollSlug}"
style="max-width: 400px; margin: 0 auto;">
<div style="background: linear-gradient(135deg, #722ed1 0%, #531dab 100%); border-radius: 12px; padding: 24px; text-align: center; color: #fff;">
<p style="margin: 0; font-size: 1rem; font-weight: 600;">Straw Poll (Card Link)</p>
<p style="margin: 8px 0 0; font-size: 0.85rem; opacity: 0.85;">${pollSlug || 'Set poll slug in block properties'}</p>
<p style="margin: 8px 0 0; font-size: 0.75rem; opacity: 0.6; font-style: italic;">Preview card with vote link renders on published page</p>
</div>
</div>
</section>`;
}
default: default:
return `<section style="padding: 40px; text-align: center;"><p>Custom block: ${type}</p></section>`; return `<section style="padding: 40px; text-align: center;"><p>Custom block: ${type}</p></section>`;
} }

View File

@ -16,6 +16,7 @@ const roleColors: Record<UserRole, string> = {
PAYMENTS_ADMIN: 'green', PAYMENTS_ADMIN: 'green',
EVENTS_ADMIN: 'cyan', EVENTS_ADMIN: 'cyan',
SOCIAL_ADMIN: 'magenta', SOCIAL_ADMIN: 'magenta',
POLLS_ADMIN: 'geekblue',
USER: 'blue', USER: 'blue',
TEMP: 'default', TEMP: 'default',
}; };

View File

@ -0,0 +1,51 @@
import { Progress, Space, Typography } from 'antd';
import type { StrawPollOption } from '@/types/api';
const { Text } = Typography;
const YES_NO_COLORS: Record<string, string> = {
Yes: '#52c41a',
No: '#ff4d4f',
Abstain: '#8c8c8c',
};
interface PollResultsProps {
options: StrawPollOption[];
totalVotes: number;
type: 'SINGLE_CHOICE' | 'YES_NO_ABSTAIN';
}
const COLORS = ['#1890ff', '#52c41a', '#faad14', '#ff4d4f', '#722ed1', '#13c2c2', '#eb2f96', '#fa8c16', '#a0d911', '#2f54eb'];
export default function PollResults({ options, totalVotes, type }: PollResultsProps) {
return (
<div>
{options.map((opt, i) => {
const count = opt.voteCount ?? opt._count?.votes ?? 0;
const pct = totalVotes > 0 ? Math.round((count / totalVotes) * 100) : 0;
const color = type === 'YES_NO_ABSTAIN'
? YES_NO_COLORS[opt.label] || COLORS[i % COLORS.length]
: COLORS[i % COLORS.length];
return (
<div key={opt.id} style={{ marginBottom: 12 }}>
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Text strong>{opt.label}</Text>
<Text type="secondary">{count} vote{count !== 1 ? 's' : ''} ({pct}%)</Text>
</Space>
<Progress
percent={pct}
showInfo={false}
strokeColor={color}
size="small"
style={{ marginTop: 4 }}
/>
</div>
);
})}
<Text type="secondary" style={{ display: 'block', marginTop: 8 }}>
Total: {totalVotes} vote{totalVotes !== 1 ? 's' : ''}
</Text>
</div>
);
}

View File

@ -59,7 +59,6 @@ import {
MobileOutlined, MobileOutlined,
DesktopOutlined, DesktopOutlined,
CalendarOutlined, CalendarOutlined,
ClearOutlined,
FormOutlined, FormOutlined,
ShareAltOutlined, ShareAltOutlined,
LockOutlined, LockOutlined,
@ -591,40 +590,6 @@ export default function DocsPage() {
const isMobile = !screens.md; const isMobile = !screens.md;
const { token } = theme.useToken(); const { token } = theme.useToken();
const { building, confirmAndBuild, isSuperAdmin } = useMkDocsBuild(); const { building, confirmAndBuild, isSuperAdmin } = useMkDocsBuild();
const [resetting, setResetting] = useState(false);
const confirmAndReset = useCallback(() => {
if (!isSuperAdmin) return;
Modal.confirm({
title: 'Reset Documentation Site',
content: (
<div>
<p>This will reset all documentation content to a baseline template.</p>
<p><strong>Preserved:</strong> header config, analytics tracking, hooks, assets, stylesheets, blog.</p>
<p><strong>Deleted:</strong> all custom content pages.</p>
<p>A backup will be created automatically.</p>
</div>
),
okText: 'Reset Site',
okButtonProps: { danger: true },
onOk: async () => {
setResetting(true);
try {
const { data } = await api.post('/docs/reset');
message.success(`Site reset complete. ${data.filesReset} files reset, ${data.filesPreserved} preserved.`);
// Refresh file tree
const treeRes = await api.get('/docs/files');
setFileTree(treeRes.data.tree || []);
setSelectedFile(null);
setFileContent('');
} catch {
message.error('Failed to reset documentation site');
} finally {
setResetting(false);
}
},
});
}, [isSuperAdmin]);
const [fileTree, setFileTree] = useState<FileNode[]>(() => getCachedTree() || []); const [fileTree, setFileTree] = useState<FileNode[]>(() => getCachedTree() || []);
const [config, setConfig] = useState<ServicesConfig | null>(null); const [config, setConfig] = useState<ServicesConfig | null>(null);
@ -800,9 +765,10 @@ export default function DocsPage() {
} }
}, [fileContentCache, messageApi]); }, [fileContentCache, messageApi]);
// Handle navigation state from command palette — auto-select a file // Handle navigation state from command palette or metadata page — auto-select a file
useEffect(() => { useEffect(() => {
const selectFile = (location.state as { selectFile?: string } | null)?.selectFile; const state = location.state as { selectFile?: string; openFile?: string } | null;
const selectFile = state?.selectFile || state?.openFile;
if (!selectFile || loading) return; if (!selectFile || loading) return;
// Expand parent directories so the file is visible in the tree // Expand parent directories so the file is visible in the tree
@ -855,8 +821,22 @@ export default function DocsPage() {
if ((e.ctrlKey || e.metaKey) && e.key === 's') { if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault(); e.preventDefault();
if (collab.active) { if (collab.active) {
// In collab mode, auto-save handles persistence — just refresh preview // In collab mode, explicitly save current content + refresh preview
previewIframeRef.current?.contentWindow?.location.reload(); if (selectedFile && collab.yText) {
const content = collab.yText.toString();
api.put(`/docs/files/${selectedFile}`, { content })
.then(() => messageApi.success('Saved'))
.catch(() => messageApi.error('Save failed'));
}
// Refresh preview with cache-buster
if (previewIframeRef.current && selectedFile) {
const url = filePathToMkDocsUrl(selectedFile);
setTimeout(() => {
if (previewIframeRef.current) {
previewIframeRef.current.src = url + '?_t=' + Date.now();
}
}, 1500);
}
} else { } else {
saveFile(); saveFile();
} }
@ -1603,13 +1583,10 @@ export default function DocsPage() {
<Tooltip title="Build static site"> <Tooltip title="Build static site">
<Button type="text" icon={<BuildOutlined />} onClick={confirmAndBuild} loading={building} size="middle" /> <Button type="text" icon={<BuildOutlined />} onClick={confirmAndBuild} loading={building} size="middle" />
</Tooltip> </Tooltip>
<Tooltip title="Reset site to baseline">
<Button type="text" danger icon={<ClearOutlined />} onClick={confirmAndReset} loading={resetting} size="middle" />
</Tooltip>
</> </>
)} )}
</Space> </Space>
), [layout, dirty, saving, saveFile, refreshPreview, mkdocsDirectUrl, token.colorBorderSecondary, isSuperAdmin, building, confirmAndBuild, resetting, confirmAndReset]); ), [layout, dirty, saving, saveFile, refreshPreview, mkdocsDirectUrl, token.colorBorderSecondary, isSuperAdmin, building, confirmAndBuild]);
// Inject header // Inject header
useEffect(() => { useEffect(() => {

View File

@ -912,6 +912,49 @@ export default function MkDocsSettingsPage() {
))} ))}
</Card> </Card>
{isSuperAdmin && (
<Card
title={<span style={{ color: token.colorError }}>Danger Zone</span>}
style={{ marginTop: 24, borderColor: token.colorError }}
size="small"
>
<Space direction="vertical" style={{ width: '100%' }}>
<Typography.Text type="secondary">
Reset all documentation content to a baseline template. A backup is created automatically.
Preserved: header config, analytics tracking, hooks, assets, stylesheets, blog.
</Typography.Text>
<Button
danger
onClick={() => {
Modal.confirm({
title: 'Reset Documentation Site',
content: (
<div>
<p>This will reset all documentation content to a baseline template.</p>
<p><strong>Preserved:</strong> header config, analytics, hooks, assets, stylesheets, blog.</p>
<p><strong>Deleted:</strong> all custom content pages.</p>
<p>A backup will be created automatically.</p>
</div>
),
okText: 'Reset Site',
okButtonProps: { danger: true },
onOk: async () => {
try {
const { data } = await api.post('/docs/reset');
message.success(`Site reset complete. ${data.filesReset} files reset, ${data.filesPreserved} preserved.`);
} catch {
message.error('Failed to reset documentation site');
}
},
});
}}
>
Reset Site to Baseline
</Button>
</Space>
</Card>
)}
</div> </div>
), ),
}, },

View File

@ -468,6 +468,9 @@ export default function SettingsPage() {
<Form.Item label="Advocacy Campaigns" name="enableInfluence" valuePropName="checked" style={{ marginBottom: 12 }}> <Form.Item label="Advocacy Campaigns" name="enableInfluence" valuePropName="checked" style={{ marginBottom: 12 }}>
<Switch /> <Switch />
</Form.Item> </Form.Item>
<Form.Item label="Straw Polls" name="enablePolls" valuePropName="checked" extra="Quick opinion polls with public landers and MkDocs widgets" style={{ marginBottom: 12 }}>
<Switch />
</Form.Item>
<Form.Item label="Map & Canvassing" name="enableMap" valuePropName="checked" style={{ marginBottom: 12 }}> <Form.Item label="Map & Canvassing" name="enableMap" valuePropName="checked" style={{ marginBottom: 12 }}>
<Switch /> <Switch />
</Form.Item> </Form.Item>

View File

@ -81,6 +81,7 @@ const roleColors: Record<UserRole, string> = {
PAYMENTS_ADMIN: 'green', PAYMENTS_ADMIN: 'green',
EVENTS_ADMIN: 'cyan', EVENTS_ADMIN: 'cyan',
SOCIAL_ADMIN: 'magenta', SOCIAL_ADMIN: 'magenta',
POLLS_ADMIN: 'geekblue',
USER: 'blue', USER: 'blue',
TEMP: 'default', TEMP: 'default',
}; };

View File

@ -0,0 +1,412 @@
import { useState, useEffect, useCallback } from 'react';
import {
Table, Button, Space, Tag, Input, Select, Drawer, Form, Switch, Grid,
DatePicker, InputNumber, Radio, Typography, Popconfirm, Divider,
Descriptions, List, Card, Tooltip, App,
} from 'antd';
import {
PlusOutlined, ReloadOutlined, CopyOutlined, PlayCircleOutlined,
PauseCircleOutlined, UndoOutlined, InboxOutlined, DeleteOutlined,
LinkOutlined, MinusCircleOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import { api } from '@/lib/api';
import PollResults from '@/components/polls/PollResults';
import type {
StrawPoll, StrawPollType, StrawPollStatus, StrawPollIdentityMode,
} from '@/types/api';
import {
STRAW_POLL_STATUS_COLORS, STRAW_POLL_STATUS_LABELS,
STRAW_POLL_TYPE_LABELS, STRAW_POLL_IDENTITY_LABELS,
STRAW_POLL_VISIBILITY_LABELS,
} from '@/types/api';
const { Search } = Input;
const { Text, Title } = Typography;
export default function StrawPollsPage() {
const { message: msg } = App.useApp();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [polls, setPolls] = useState<StrawPoll[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<StrawPollStatus | undefined>();
// Drawers
const [createOpen, setCreateOpen] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const [selectedPoll, setSelectedPoll] = useState<StrawPoll | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
const [createForm] = Form.useForm();
const [pollType, setPollType] = useState<StrawPollType>('SINGLE_CHOICE');
const fetchPolls = useCallback(async () => {
setLoading(true);
try {
const params: Record<string, any> = { page, limit: 20 };
if (search) params.search = search;
if (statusFilter) params.status = statusFilter;
const { data } = await api.get('/straw-polls', { params });
setPolls(data.polls);
setTotal(data.total);
} catch {
msg.error('Failed to load polls');
} finally {
setLoading(false);
}
}, [page, search, statusFilter]);
useEffect(() => { fetchPolls(); }, [fetchPolls]);
const fetchDetail = async (id: string) => {
setDetailLoading(true);
try {
const { data } = await api.get(`/straw-polls/${id}`);
setSelectedPoll(data);
setDetailOpen(true);
} catch {
msg.error('Failed to load poll details');
} finally {
setDetailLoading(false);
}
};
const handleCreate = async (values: any) => {
try {
const payload = {
...values,
closesAt: values.closesAt ? values.closesAt.toISOString() : undefined,
options: values.type === 'YES_NO_ABSTAIN' ? undefined : values.options,
};
await api.post('/straw-polls', payload);
msg.success('Poll created');
setCreateOpen(false);
createForm.resetFields();
fetchPolls();
} catch {
msg.error('Failed to create poll');
}
};
const handleLifecycle = async (id: string, action: string) => {
try {
await api.post(`/straw-polls/${id}/${action}`);
msg.success(`Poll ${action}d`);
fetchPolls();
if (selectedPoll?.id === id) fetchDetail(id);
} catch (err: any) {
msg.error(err.response?.data?.error || `Failed to ${action} poll`);
}
};
const handleDelete = async (id: string) => {
try {
await api.delete(`/straw-polls/${id}`);
msg.success('Poll deleted');
fetchPolls();
if (selectedPoll?.id === id) setDetailOpen(false);
} catch {
msg.error('Failed to delete poll');
}
};
const copyLink = (slug: string) => {
const url = `${window.location.origin}/straw-poll/${slug}`;
navigator.clipboard.writeText(url);
msg.success('Link copied');
};
const columns = [
{
title: 'Title',
dataIndex: 'title',
key: 'title',
render: (title: string, record: StrawPoll) => (
<a onClick={() => fetchDetail(record.id)}>{title}</a>
),
},
{
title: 'Type',
dataIndex: 'type',
key: 'type',
width: 150,
render: (type: StrawPollType) => <Tag>{STRAW_POLL_TYPE_LABELS[type]}</Tag>,
},
{
title: 'Status',
dataIndex: 'status',
key: 'status',
width: 100,
render: (status: StrawPollStatus) => (
<Tag color={STRAW_POLL_STATUS_COLORS[status]}>{STRAW_POLL_STATUS_LABELS[status]}</Tag>
),
},
{
title: 'Identity',
dataIndex: 'identityMode',
key: 'identityMode',
width: 130,
render: (mode: StrawPollIdentityMode) => STRAW_POLL_IDENTITY_LABELS[mode],
},
{
title: 'Votes',
key: 'votes',
width: 80,
render: (_: any, record: StrawPoll) => record._count?.votes ?? 0,
},
{
title: 'Created',
dataIndex: 'createdAt',
key: 'createdAt',
width: 120,
render: (d: string) => dayjs(d).format('MMM D, YYYY'),
},
{
title: 'Actions',
key: 'actions',
width: 200,
render: (_: any, record: StrawPoll) => (
<Space size="small">
<Tooltip title="Copy public link">
<Button size="small" icon={<CopyOutlined />} onClick={() => copyLink(record.slug)} />
</Tooltip>
{record.status === 'DRAFT' && (
<Button size="small" type="primary" icon={<PlayCircleOutlined />} onClick={() => handleLifecycle(record.id, 'activate')}>
Activate
</Button>
)}
{record.status === 'ACTIVE' && (
<Button size="small" icon={<PauseCircleOutlined />} onClick={() => handleLifecycle(record.id, 'close')}>
Close
</Button>
)}
{record.status === 'CLOSED' && (
<Space size="small">
<Button size="small" icon={<UndoOutlined />} onClick={() => handleLifecycle(record.id, 'reopen')}>Reopen</Button>
<Button size="small" icon={<InboxOutlined />} onClick={() => handleLifecycle(record.id, 'archive')}>Archive</Button>
</Space>
)}
<Popconfirm title="Delete this poll?" onConfirm={() => handleDelete(record.id)}>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
},
];
const drawerOpen = createOpen || detailOpen;
const drawerWidth = isMobile ? 0 : (detailOpen ? 600 : 520);
return (
<>
<div style={{ padding: 24, marginRight: drawerOpen ? drawerWidth : 0, transition: 'margin-right 0.15s cubic-bezier(0.2, 0, 0, 1)' }}>
<Space style={{ width: '100%', justifyContent: 'space-between', marginBottom: 16 }}>
<Title level={4} style={{ margin: 0 }}>Straw Polls</Title>
<Space>
<Search placeholder="Search polls..." allowClear onSearch={setSearch} style={{ width: 250 }} />
<Select
placeholder="Status"
allowClear
style={{ width: 130 }}
onChange={setStatusFilter}
options={Object.entries(STRAW_POLL_STATUS_LABELS).map(([k, v]) => ({ label: v, value: k }))}
/>
<Button icon={<ReloadOutlined />} onClick={fetchPolls} />
<Button type="primary" icon={<PlusOutlined />} onClick={() => { createForm.resetFields(); setPollType('SINGLE_CHOICE'); setCreateOpen(true); }}>
New Poll
</Button>
</Space>
</Space>
<Table
dataSource={polls}
columns={columns}
rowKey="id"
loading={loading}
pagination={{ current: page, total, pageSize: 20, onChange: setPage, showSizeChanger: false }}
/>
</div>
{/* Create Drawer */}
<Drawer
title="Create Straw Poll"
open={createOpen}
onClose={() => setCreateOpen(false)}
width={isMobile ? '100%' : 520}
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
extra={<Button type="primary" onClick={() => createForm.submit()}>Create</Button>}
>
<Form form={createForm} layout="vertical" onFinish={handleCreate} initialValues={{ type: 'SINGLE_CHOICE', identityMode: 'ANONYMOUS', resultVisibility: 'LIVE', allowComments: true, isPrivate: false }}>
<Form.Item name="title" label="Title" rules={[{ required: true, max: 200 }]}>
<Input placeholder="e.g., Should we add bike lanes on Main Street?" />
</Form.Item>
<Form.Item name="description" label="Description">
<Input.TextArea rows={3} maxLength={2000} />
</Form.Item>
<Form.Item name="type" label="Poll Type" rules={[{ required: true }]}>
<Radio.Group onChange={(e) => setPollType(e.target.value)}>
<Radio.Button value="SINGLE_CHOICE">Single Choice</Radio.Button>
<Radio.Button value="YES_NO_ABSTAIN">Yes / No / Abstain</Radio.Button>
</Radio.Group>
</Form.Item>
{pollType === 'SINGLE_CHOICE' && (
<Form.List name="options" initialValue={[{ label: '' }, { label: '' }]}>
{(fields, { add, remove }) => (
<div>
<Text strong>Options</Text>
{fields.map((field, index) => (
<Space key={field.key} style={{ display: 'flex', marginBottom: 8, marginTop: index === 0 ? 8 : 0 }} align="baseline">
<Form.Item {...field} name={[field.name, 'label']} rules={[{ required: true, message: 'Option required' }]} style={{ marginBottom: 0, flex: 1 }}>
<Input placeholder={`Option ${index + 1}`} />
</Form.Item>
{fields.length > 2 && (
<MinusCircleOutlined onClick={() => remove(field.name)} style={{ color: '#ff4d4f' }} />
)}
</Space>
))}
{fields.length < 20 && (
<Button type="dashed" onClick={() => add({ label: '' })} icon={<PlusOutlined />} style={{ width: '100%', marginTop: 8 }}>
Add Option
</Button>
)}
</div>
)}
</Form.List>
)}
<Divider />
<Form.Item name="identityMode" label="Identity Mode">
<Select options={Object.entries(STRAW_POLL_IDENTITY_LABELS).map(([k, v]) => ({ label: v, value: k }))} />
</Form.Item>
<Form.Item name="resultVisibility" label="Result Visibility">
<Select options={Object.entries(STRAW_POLL_VISIBILITY_LABELS).map(([k, v]) => ({ label: v, value: k }))} />
</Form.Item>
<Form.Item name="closesAt" label="Auto-close Date">
<DatePicker showTime format="YYYY-MM-DD HH:mm" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="closeThreshold" label="Auto-close Vote Threshold">
<InputNumber min={1} max={100000} style={{ width: '100%' }} placeholder="Close after N votes" />
</Form.Item>
<Space>
<Form.Item name="allowComments" valuePropName="checked"><Switch checkedChildren="Comments" unCheckedChildren="No Comments" /></Form.Item>
<Form.Item name="isPrivate" valuePropName="checked"><Switch checkedChildren="Private" unCheckedChildren="Public" /></Form.Item>
</Space>
</Form>
</Drawer>
{/* Detail Drawer */}
<Drawer
title={selectedPoll?.title || 'Poll Detail'}
open={detailOpen}
onClose={() => setDetailOpen(false)}
width={isMobile ? '100%' : 600}
loading={detailLoading}
mask={false}
rootStyle={{ position: 'absolute', top: 64, height: 'calc(100vh - 64px)' }}
>
{selectedPoll && (
<div>
<Descriptions column={2} size="small" bordered style={{ marginBottom: 16 }}>
<Descriptions.Item label="Status">
<Tag color={STRAW_POLL_STATUS_COLORS[selectedPoll.status]}>{STRAW_POLL_STATUS_LABELS[selectedPoll.status]}</Tag>
</Descriptions.Item>
<Descriptions.Item label="Type">{STRAW_POLL_TYPE_LABELS[selectedPoll.type]}</Descriptions.Item>
<Descriptions.Item label="Identity">{STRAW_POLL_IDENTITY_LABELS[selectedPoll.identityMode]}</Descriptions.Item>
<Descriptions.Item label="Visibility">{STRAW_POLL_VISIBILITY_LABELS[selectedPoll.resultVisibility]}</Descriptions.Item>
<Descriptions.Item label="Created">{dayjs(selectedPoll.createdAt).format('MMM D, YYYY h:mm A')}</Descriptions.Item>
<Descriptions.Item label="Closes">{selectedPoll.closesAt ? dayjs(selectedPoll.closesAt).format('MMM D, YYYY h:mm A') : 'Manual'}</Descriptions.Item>
</Descriptions>
{selectedPoll.description && (
<Card size="small" style={{ marginBottom: 16 }}>
<Text>{selectedPoll.description}</Text>
</Card>
)}
{/* Lifecycle Controls */}
<Space style={{ marginBottom: 16 }}>
{selectedPoll.status === 'DRAFT' && (
<Button type="primary" icon={<PlayCircleOutlined />} onClick={() => handleLifecycle(selectedPoll.id, 'activate')}>Activate</Button>
)}
{selectedPoll.status === 'ACTIVE' && (
<Button icon={<PauseCircleOutlined />} onClick={() => handleLifecycle(selectedPoll.id, 'close')}>Close</Button>
)}
{selectedPoll.status === 'CLOSED' && (
<>
<Button icon={<UndoOutlined />} onClick={() => handleLifecycle(selectedPoll.id, 'reopen')}>Reopen</Button>
<Button icon={<InboxOutlined />} onClick={() => handleLifecycle(selectedPoll.id, 'archive')}>Archive</Button>
</>
)}
<Button icon={<LinkOutlined />} onClick={() => copyLink(selectedPoll.slug)}>Copy Link</Button>
</Space>
{/* Results */}
<Divider>Vote Results</Divider>
{selectedPoll.options && (
<PollResults
options={selectedPoll.options}
totalVotes={selectedPoll._count?.votes ?? 0}
type={selectedPoll.type}
/>
)}
{/* Voters */}
{selectedPoll.votes && selectedPoll.votes.length > 0 && (
<>
<Divider>Voters ({selectedPoll.votes.length})</Divider>
<List
size="small"
dataSource={selectedPoll.votes}
renderItem={(vote: any) => (
<List.Item
extra={
<Popconfirm title="Remove this vote?" onConfirm={() => api.delete(`/straw-polls/${selectedPoll.id}/votes/${vote.id}`).then(() => fetchDetail(selectedPoll.id))}>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
}
>
<List.Item.Meta
title={vote.user?.name || vote.voterName || 'Anonymous'}
description={`Voted for: ${selectedPoll.options?.find(o => o.id === vote.optionId)?.label || 'Unknown'}${dayjs(vote.createdAt).format('MMM D, h:mm A')}`}
/>
</List.Item>
)}
/>
</>
)}
{/* Comments */}
{selectedPoll.comments && selectedPoll.comments.length > 0 && (
<>
<Divider>Comments ({selectedPoll.comments.length})</Divider>
<List
size="small"
dataSource={selectedPoll.comments}
renderItem={(comment: any) => (
<List.Item
extra={
<Popconfirm title="Delete comment?" onConfirm={() => api.delete(`/straw-polls/${selectedPoll.id}/comments/${comment.id}`).then(() => fetchDetail(selectedPoll.id))}>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
}
>
<List.Item.Meta title={comment.authorName} description={comment.content} />
<Text type="secondary" style={{ fontSize: 12 }}>{dayjs(comment.createdAt).format('MMM D, h:mm A')}</Text>
</List.Item>
)}
/>
</>
)}
</div>
)}
</Drawer>
</>
);
}

View File

@ -0,0 +1,293 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import {
Card, Typography, Tag, Radio, Button, Input, Space, Spin, Result,
Divider, List, Form, Grid, App,
} from 'antd';
import { CheckCircleFilled, ShareAltOutlined } from '@ant-design/icons';
import axios from 'axios';
import dayjs from 'dayjs';
import PollResults from '@/components/polls/PollResults';
import type { StrawPoll } from '@/types/api';
import { STRAW_POLL_STATUS_LABELS, STRAW_POLL_TYPE_LABELS } from '@/types/api';
import { useAuthStore } from '@/stores/auth.store';
const { Title, Text, Paragraph } = Typography;
const apiBase = '/api';
export default function StrawPollPage() {
const { slug } = useParams<{ slug: string }>();
const { message: msg } = App.useApp();
const { user, accessToken } = useAuthStore();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [poll, setPoll] = useState<StrawPoll | null>(null);
const [loading, setLoading] = useState(true);
const [selectedOption, setSelectedOption] = useState<string>('');
const [voterName, setVoterName] = useState('');
const [submitting, setSubmitting] = useState(false);
const [hasVoted, setHasVoted] = useState(false);
const [commentForm] = Form.useForm();
const sseRef = useRef<EventSource | null>(null);
const storedToken = localStorage.getItem(`straw_poll_voter_token_${slug}`);
const fetchPoll = useCallback(async () => {
if (!slug) return;
try {
const params: Record<string, string> = {};
if (storedToken) params.voterToken = storedToken;
const headers: Record<string, string> = {};
if (accessToken) headers.Authorization = `Bearer ${accessToken}`;
const { data } = await axios.get(`${apiBase}/straw-polls/public/${slug}`, { params, headers });
setPoll(data);
setHasVoted(!!data.hasVoted);
} catch {
setPoll(null);
} finally {
setLoading(false);
}
}, [slug, storedToken, accessToken]);
useEffect(() => { fetchPoll(); }, [fetchPoll]);
// SSE for live results
useEffect(() => {
if (!slug || !poll || poll.resultVisibility !== 'LIVE') return;
const es = new EventSource(`${apiBase}/straw-polls/public/${slug}/live`);
sseRef.current = es;
es.addEventListener('vote_update', (e) => {
try {
const data = JSON.parse(e.data);
setPoll(prev => {
if (!prev || !prev.options) return prev;
const updated = prev.options.map(opt => {
const count = data.optionCounts?.find((c: any) => c.optionId === opt.id);
return count ? { ...opt, voteCount: count.count } : opt;
});
return { ...prev, options: updated, totalVotes: data.totalVotes };
});
} catch {}
});
es.addEventListener('poll_closed', () => {
setPoll(prev => prev ? { ...prev, status: 'CLOSED' } : prev);
msg.info('This poll has been closed');
});
es.addEventListener('comment_added', () => {
fetchPoll(); // Refresh to get new comment
});
return () => { es.close(); };
}, [slug, poll?.resultVisibility]);
const handleVote = async () => {
if (!selectedOption || !slug) return;
setSubmitting(true);
try {
const body: any = { optionId: selectedOption };
if (voterName) body.voterName = voterName;
if (storedToken) body.voterToken = storedToken;
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (accessToken) headers.Authorization = `Bearer ${accessToken}`;
const { data } = await axios.post(`${apiBase}/straw-polls/public/${slug}/vote`, body, { headers });
if (data.voterToken) {
localStorage.setItem(`straw_poll_voter_token_${slug}`, data.voterToken);
}
setHasVoted(true);
msg.success('Vote submitted!');
fetchPoll();
} catch (err: any) {
msg.error(err.response?.data?.error || 'Failed to vote');
} finally {
setSubmitting(false);
}
};
const handleComment = async (values: any) => {
if (!slug) return;
try {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (accessToken) headers.Authorization = `Bearer ${accessToken}`;
await axios.post(`${apiBase}/straw-polls/public/${slug}/comment`, values, { headers });
commentForm.resetFields();
fetchPoll();
} catch {
msg.error('Failed to post comment');
}
};
if (loading) return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
if (!poll) return <Result status="404" title="Poll not found" />;
if (poll.requiresAuth && !user) return <Result status="403" title="This poll requires authentication" />;
const showVoteForm = poll.status === 'ACTIVE' && !hasVoted;
const showResults = poll.showResults || hasVoted;
return (
<div style={{ padding: isMobile ? 16 : 32, maxWidth: 700, margin: '0 auto' }}>
{/* Hero */}
<div style={{ textAlign: 'center', marginBottom: 32 }}>
<Space>
<Tag>{STRAW_POLL_TYPE_LABELS[poll.type]}</Tag>
<Tag color={poll.status === 'ACTIVE' ? 'green' : poll.status === 'CLOSED' ? 'orange' : 'default'}>
{STRAW_POLL_STATUS_LABELS[poll.status]}
</Tag>
</Space>
<Title level={2} style={{ marginTop: 12, marginBottom: 8 }}>{poll.title}</Title>
{poll.description && <Paragraph type="secondary">{poll.description}</Paragraph>}
{poll.createdBy && <Text type="secondary">by {poll.createdBy.name}</Text>}
{poll.closesAt && poll.status === 'ACTIVE' && (
<div style={{ marginTop: 8 }}>
<Text type="secondary">Closes {dayjs(poll.closesAt).format('MMM D, YYYY h:mm A')}</Text>
</div>
)}
</div>
{/* Vote Form */}
{showVoteForm && (
<Card style={{ marginBottom: 24 }}>
<Title level={4}>Cast Your Vote</Title>
{poll.type === 'YES_NO_ABSTAIN' ? (
<Space direction={isMobile ? 'vertical' : 'horizontal'} style={{ width: '100%', justifyContent: 'center', marginBottom: 16 }}>
{poll.options?.map(opt => (
<Button
key={opt.id}
type={selectedOption === opt.id ? 'primary' : 'default'}
size="large"
onClick={() => setSelectedOption(opt.id)}
style={{
minWidth: 120,
...(opt.label === 'Yes' && selectedOption === opt.id ? { backgroundColor: '#52c41a', borderColor: '#52c41a' } : {}),
...(opt.label === 'No' && selectedOption === opt.id ? { backgroundColor: '#ff4d4f', borderColor: '#ff4d4f' } : {}),
}}
>
{opt.label}
</Button>
))}
</Space>
) : (
<Radio.Group
value={selectedOption}
onChange={(e) => setSelectedOption(e.target.value)}
style={{ width: '100%', marginBottom: 16 }}
>
<Space direction="vertical" style={{ width: '100%' }}>
{poll.options?.map(opt => (
<Radio key={opt.id} value={opt.id} style={{ fontSize: 16, padding: '8px 0' }}>
{opt.label}
</Radio>
))}
</Space>
</Radio.Group>
)}
{(poll.identityMode === 'ANONYMOUS' || poll.identityMode === 'MIXED') && !user && (
<Input
placeholder="Your name (optional)"
value={voterName}
onChange={(e) => setVoterName(e.target.value)}
style={{ marginBottom: 16, maxWidth: 300 }}
/>
)}
<Button
type="primary"
size="large"
disabled={!selectedOption}
loading={submitting}
onClick={handleVote}
block
>
Submit Vote
</Button>
</Card>
)}
{/* Already Voted */}
{hasVoted && poll.status === 'ACTIVE' && (
<Card style={{ marginBottom: 24, textAlign: 'center' }}>
<CheckCircleFilled style={{ fontSize: 32, color: '#52c41a', marginBottom: 8 }} />
<Title level={4} style={{ marginTop: 0 }}>You've voted!</Title>
<Text type="secondary">Your vote has been recorded.</Text>
</Card>
)}
{/* Results */}
{showResults && poll.options && (
<Card style={{ marginBottom: 24 }}>
<Title level={4}>Results</Title>
<PollResults
options={poll.options}
totalVotes={poll.totalVotes ?? poll._count?.votes ?? 0}
type={poll.type}
/>
</Card>
)}
{!showResults && poll.resultVisibility !== 'LIVE' && poll.resultVisibility !== 'PUBLIC_ALWAYS' && (
<Card style={{ marginBottom: 24, textAlign: 'center' }}>
<Text type="secondary">
Results will be visible {poll.resultVisibility === 'AFTER_VOTE' ? 'after you vote' : poll.resultVisibility === 'AFTER_CLOSE' ? 'when the poll closes' : ''}
</Text>
</Card>
)}
{/* Share */}
<div style={{ textAlign: 'center', marginBottom: 24 }}>
<Button
icon={<ShareAltOutlined />}
onClick={() => {
navigator.clipboard.writeText(window.location.href);
msg.success('Link copied!');
}}
>
Share This Poll
</Button>
</div>
{/* Comments */}
{poll.allowComments && (
<>
<Divider>Comments</Divider>
<Form form={commentForm} layout="inline" onFinish={handleComment} style={{ marginBottom: 16, display: 'flex', gap: 8, flexWrap: 'wrap' }}>
<Form.Item name="authorName" rules={[{ required: true, message: 'Name required' }]} style={{ flex: '0 0 auto' }}>
<Input placeholder="Your name" defaultValue={user?.name || ''} />
</Form.Item>
<Form.Item name="content" rules={[{ required: true, message: 'Comment required' }]} style={{ flex: 1, minWidth: 200 }}>
<Input placeholder="Add a comment..." />
</Form.Item>
<Button type="primary" htmlType="submit">Post</Button>
</Form>
{poll.comments && poll.comments.length > 0 ? (
<List
dataSource={poll.comments}
renderItem={(comment: any) => (
<List.Item>
<List.Item.Meta
title={comment.authorName}
description={comment.content}
/>
<Text type="secondary" style={{ fontSize: 12 }}>{dayjs(comment.createdAt).format('MMM D, h:mm A')}</Text>
</List.Item>
)}
/>
) : (
<Text type="secondary">No comments yet.</Text>
)}
</>
)}
</div>
);
}

View File

@ -0,0 +1,63 @@
import { useState, useEffect } from 'react';
import { Card, Row, Col, Tag, Typography, Spin, Empty, Grid } from 'antd';
import { useNavigate } from 'react-router-dom';
import axios from 'axios';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import type { StrawPoll } from '@/types/api';
import { STRAW_POLL_TYPE_LABELS } from '@/types/api';
dayjs.extend(relativeTime);
const { Title, Text, Paragraph } = Typography;
const apiBase = '/api';
export default function StrawPollsListPage() {
const navigate = useNavigate();
const screens = Grid.useBreakpoint();
const isMobile = !screens.md;
const [polls, setPolls] = useState<StrawPoll[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
axios.get(`${apiBase}/straw-polls/public`, { params: { limit: 50 } })
.then(res => setPolls(res.data.polls))
.catch(() => {})
.finally(() => setLoading(false));
}, []);
if (loading) return <Spin size="large" style={{ display: 'block', margin: '80px auto' }} />;
if (polls.length === 0) return <Empty description="No active polls" style={{ marginTop: 80 }} />;
return (
<div style={{ padding: isMobile ? 16 : 32, maxWidth: 1000, margin: '0 auto' }}>
<Title level={2} style={{ textAlign: 'center', marginBottom: 32 }}>Straw Polls</Title>
<Row gutter={[16, 16]}>
{polls.map(poll => (
<Col key={poll.id} xs={24} sm={12} md={8}>
<Card
hoverable
onClick={() => navigate(`/straw-poll/${poll.slug}`)}
style={{ height: '100%' }}
>
<Tag style={{ marginBottom: 8 }}>{STRAW_POLL_TYPE_LABELS[poll.type]}</Tag>
<Title level={5} style={{ marginTop: 0, marginBottom: 8 }}>{poll.title}</Title>
{poll.description && (
<Paragraph ellipsis={{ rows: 2 }} type="secondary" style={{ marginBottom: 8 }}>
{poll.description}
</Paragraph>
)}
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<Text type="secondary">{poll._count?.votes ?? 0} votes</Text>
{poll.closesAt && (
<Text type="secondary">Closes {dayjs(poll.closesAt).fromNow()}</Text>
)}
</div>
</Card>
</Col>
))}
</Row>
</div>
);
}

View File

@ -460,8 +460,8 @@ export default function SmsSetupPage() {
configures auto-start, and launches the server. configures auto-start, and launches the server.
</Paragraph> </Paragraph>
<div style={{ background: 'rgba(0,0,0,0.1)', padding: 12, borderRadius: 6 }}> <div style={{ background: 'rgba(0,0,0,0.1)', padding: 12, borderRadius: 6 }}>
<CmdLine comment="Clone the SMS server (first time only)" cmd="pkg install -y git && git clone https://gitea.bnkops.com/admin/campaign_connector.git ~/sms-server" /> <CmdLine comment="Clone the SMS server (first time only)" cmd="pkg install -y git && git clone --depth 1 --filter=blob:none --sparse https://gitea.bnkops.com/admin/changemaker.lite.git ~/sms-server && cd ~/sms-server && git sparse-checkout set termux-sms" />
<CmdLine comment="Run the setup script with your API key" cmd={`bash ~/sms-server/android/setup.sh ${generatedKey}`} /> <CmdLine comment="Run the setup script with your API key" cmd={`bash ~/sms-server/termux-sms/setup.sh ${generatedKey}`} />
</div> </div>
<Paragraph style={{ marginTop: 12 }}> <Paragraph style={{ marginTop: 12 }}>
The script will: The script will:
@ -517,7 +517,7 @@ export default function SmsSetupPage() {
<CmdLine comment="Update key and restart service" cmd={`sed -i '/SMS_API_SECRET/d' ~/.bashrc && echo 'export SMS_API_SECRET="${generatedKey}"' >> ~/.bashrc && source ~/.bashrc && sv restart sms-api`} /> <CmdLine comment="Update key and restart service" cmd={`sed -i '/SMS_API_SECRET/d' ~/.bashrc && echo 'export SMS_API_SECRET="${generatedKey}"' >> ~/.bashrc && source ~/.bashrc && sv restart sms-api`} />
</div> </div>
<Paragraph type="secondary" style={{ marginTop: 8, marginBottom: 0 }}> <Paragraph type="secondary" style={{ marginTop: 8, marginBottom: 0 }}>
If <Text code>sv</Text> is not installed yet, run the full setup: <Text code copyable={{ text: `cd ~/sms-server && git pull && bash android/setup-services.sh` }}>cd ~/sms-server && git pull && bash android/setup-services.sh</Text> If <Text code>sv</Text> is not installed yet, run the full setup: <Text code copyable={{ text: `cd ~/sms-server && git pull && bash termux-sms/setup-services.sh` }}>cd ~/sms-server && git pull && bash termux-sms/setup-services.sh</Text>
</Paragraph> </Paragraph>
</div> </div>
} }

View File

@ -13,7 +13,7 @@ export interface AppOutletContext {
setPageHeader: (config: PageHeaderConfig | null) => void; setPageHeader: (config: PageHeaderConfig | null) => void;
} }
export type UserRole = 'SUPER_ADMIN' | 'INFLUENCE_ADMIN' | 'MAP_ADMIN' | 'BROADCAST_ADMIN' | 'CONTENT_ADMIN' | 'MEDIA_ADMIN' | 'PAYMENTS_ADMIN' | 'EVENTS_ADMIN' | 'SOCIAL_ADMIN' | 'USER' | 'TEMP'; export type UserRole = 'SUPER_ADMIN' | 'INFLUENCE_ADMIN' | 'MAP_ADMIN' | 'BROADCAST_ADMIN' | 'CONTENT_ADMIN' | 'MEDIA_ADMIN' | 'PAYMENTS_ADMIN' | 'EVENTS_ADMIN' | 'SOCIAL_ADMIN' | 'POLLS_ADMIN' | 'USER' | 'TEMP';
export type UserStatus = 'ACTIVE' | 'INACTIVE' | 'SUSPENDED' | 'EXPIRED' | 'PENDING_VERIFICATION' | 'PENDING_APPROVAL'; export type UserStatus = 'ACTIVE' | 'INACTIVE' | 'SUSPENDED' | 'EXPIRED' | 'PENDING_VERIFICATION' | 'PENDING_APPROVAL';
@ -101,7 +101,7 @@ export interface UsersListParams {
status?: UserStatus; status?: UserStatus;
} }
export const ADMIN_ROLES: UserRole[] = ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN', 'BROADCAST_ADMIN', 'CONTENT_ADMIN', 'MEDIA_ADMIN', 'PAYMENTS_ADMIN', 'EVENTS_ADMIN', 'SOCIAL_ADMIN']; export const ADMIN_ROLES: UserRole[] = ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN', 'BROADCAST_ADMIN', 'CONTENT_ADMIN', 'MEDIA_ADMIN', 'PAYMENTS_ADMIN', 'EVENTS_ADMIN', 'SOCIAL_ADMIN', 'POLLS_ADMIN'];
// Module-specific role groups // Module-specific role groups
export const INFLUENCE_ROLES: UserRole[] = ['SUPER_ADMIN', 'INFLUENCE_ADMIN']; export const INFLUENCE_ROLES: UserRole[] = ['SUPER_ADMIN', 'INFLUENCE_ADMIN'];
@ -114,6 +114,7 @@ export const EVENTS_ROLES: UserRole[] = ['SUPER_ADMIN', 'EVENTS_ADMIN'];
export const SOCIAL_ROLES: UserRole[] = ['SUPER_ADMIN', 'SOCIAL_ADMIN']; export const SOCIAL_ROLES: UserRole[] = ['SUPER_ADMIN', 'SOCIAL_ADMIN'];
export const SYSTEM_ROLES: UserRole[] = ['SUPER_ADMIN']; export const SYSTEM_ROLES: UserRole[] = ['SUPER_ADMIN'];
export const SCHEDULING_ROLES: UserRole[] = ['SUPER_ADMIN', 'MAP_ADMIN', 'EVENTS_ADMIN']; export const SCHEDULING_ROLES: UserRole[] = ['SUPER_ADMIN', 'MAP_ADMIN', 'EVENTS_ADMIN'];
export const POLLS_ROLES: UserRole[] = ['SUPER_ADMIN', 'POLLS_ADMIN', 'INFLUENCE_ADMIN'];
// --- User Provisioning --- // --- User Provisioning ---
@ -1169,6 +1170,7 @@ export interface SiteSettings {
enableMeetingPlanner: boolean; enableMeetingPlanner: boolean;
enableTicketedEvents: boolean; enableTicketedEvents: boolean;
enableSocialCalendar: boolean; enableSocialCalendar: boolean;
enablePolls: boolean;
enableDocsCollaboration: boolean; enableDocsCollaboration: boolean;
requireEventApproval: boolean; requireEventApproval: boolean;
autoSyncPeopleToMap: boolean; autoSyncPeopleToMap: boolean;
@ -3356,3 +3358,98 @@ export interface CalendarExportToken {
createdAt: string; createdAt: string;
} }
// ===== Straw Polls =====
export type StrawPollType = 'SINGLE_CHOICE' | 'YES_NO_ABSTAIN';
export type StrawPollStatus = 'DRAFT' | 'ACTIVE' | 'CLOSED' | 'ARCHIVED';
export type StrawPollIdentityMode = 'ANONYMOUS' | 'TOKEN_GATED' | 'AUTHENTICATED' | 'MIXED';
export type StrawPollResultVisibility = 'LIVE' | 'AFTER_VOTE' | 'AFTER_CLOSE' | 'CREATOR_ONLY' | 'PUBLIC_ALWAYS';
export const STRAW_POLL_STATUS_COLORS: Record<StrawPollStatus, string> = {
DRAFT: 'default',
ACTIVE: 'green',
CLOSED: 'orange',
ARCHIVED: 'red',
};
export const STRAW_POLL_STATUS_LABELS: Record<StrawPollStatus, string> = {
DRAFT: 'Draft',
ACTIVE: 'Active',
CLOSED: 'Closed',
ARCHIVED: 'Archived',
};
export const STRAW_POLL_TYPE_LABELS: Record<StrawPollType, string> = {
SINGLE_CHOICE: 'Single Choice',
YES_NO_ABSTAIN: 'Yes / No / Abstain',
};
export const STRAW_POLL_IDENTITY_LABELS: Record<StrawPollIdentityMode, string> = {
ANONYMOUS: 'Anonymous',
TOKEN_GATED: 'Token-Gated',
AUTHENTICATED: 'Login Required',
MIXED: 'Mixed',
};
export const STRAW_POLL_VISIBILITY_LABELS: Record<StrawPollResultVisibility, string> = {
LIVE: 'Live Results',
AFTER_VOTE: 'After Voting',
AFTER_CLOSE: 'After Close',
CREATOR_ONLY: 'Creator Only',
PUBLIC_ALWAYS: 'Public Always',
};
export interface StrawPollOption {
id: string;
label: string;
sortOrder: number;
voteCount?: number;
_count?: { votes: number };
}
export interface StrawPollVote {
id: string;
optionId: string;
userId: string | null;
voterName: string | null;
createdAt: string;
user?: { id: string; name: string | null; email: string };
}
export interface StrawPollComment {
id: string;
authorName: string;
content: string;
userId: string | null;
createdAt: string;
}
export interface StrawPoll {
id: string;
slug: string;
title: string;
description: string | null;
type: StrawPollType;
status: StrawPollStatus;
identityMode: StrawPollIdentityMode;
resultVisibility: StrawPollResultVisibility;
allowComments: boolean;
closesAt: string | null;
closeThreshold: number | null;
autoCloseJobId: string | null;
isPrivate: boolean;
createdByUserId: string;
createdBy?: { id: string; name: string | null; email: string };
createdAt: string;
updatedAt: string;
options?: StrawPollOption[];
votes?: StrawPollVote[];
comments?: StrawPollComment[];
_count?: { votes: number; comments: number; options: number };
// Public view extras
totalVotes?: number;
showResults?: boolean;
hasVoted?: boolean;
requiresAuth?: boolean;
}

135
api/package-lock.json generated
View File

@ -1656,14 +1656,6 @@
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz", "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz",
"integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==" "integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow=="
}, },
"node_modules/@isaacs/cliui": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz",
"integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==",
"engines": {
"node": ">=18"
}
},
"node_modules/@js-temporal/polyfill": { "node_modules/@js-temporal/polyfill": {
"version": "0.5.1", "version": "0.5.1",
"resolved": "https://registry.npmjs.org/@js-temporal/polyfill/-/polyfill-0.5.1.tgz", "resolved": "https://registry.npmjs.org/@js-temporal/polyfill/-/polyfill-0.5.1.tgz",
@ -1901,6 +1893,7 @@
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz", "resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
"integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==", "integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
"dev": true, "dev": true,
"license": "MIT",
"peerDependencies": { "peerDependencies": {
"@types/express": "*" "@types/express": "*"
} }
@ -2102,9 +2095,9 @@
} }
}, },
"node_modules/ajv": { "node_modules/ajv": {
"version": "8.17.1", "version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1", "fast-uri": "^3.0.1",
@ -2216,14 +2209,11 @@
} }
}, },
"node_modules/balanced-match": { "node_modules/balanced-match": {
"version": "4.0.2", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==", "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dependencies": {
"jackspeak": "^4.2.3"
},
"engines": { "engines": {
"node": "20 || >=22" "node": "18 || 20 || >=22"
} }
}, },
"node_modules/bcryptjs": { "node_modules/bcryptjs": {
@ -2260,14 +2250,14 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "5.0.2", "version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dependencies": { "dependencies": {
"balanced-match": "^4.0.2" "balanced-match": "^4.0.2"
}, },
"engines": { "engines": {
"node": "20 || >=22" "node": "18 || 20 || >=22"
} }
}, },
"node_modules/buffer-equal-constant-time": { "node_modules/buffer-equal-constant-time": {
@ -2548,6 +2538,7 @@
"version": "1.4.7", "version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz", "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==", "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"license": "MIT",
"dependencies": { "dependencies": {
"cookie": "0.7.2", "cookie": "0.7.2",
"cookie-signature": "1.0.6" "cookie-signature": "1.0.6"
@ -2700,15 +2691,15 @@
} }
}, },
"node_modules/drizzle-kit": { "node_modules/drizzle-kit": {
"version": "0.31.9", "version": "0.31.10",
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.9.tgz", "resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz",
"integrity": "sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg==", "integrity": "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"@drizzle-team/brocli": "^0.10.2", "@drizzle-team/brocli": "^0.10.2",
"@esbuild-kit/esm-loader": "^2.5.5", "@esbuild-kit/esm-loader": "^2.5.5",
"esbuild": "^0.25.4", "esbuild": "^0.25.4",
"esbuild-register": "^3.5.0" "tsx": "^4.21.0"
}, },
"bin": { "bin": {
"drizzle-kit": "bin.cjs" "drizzle-kit": "bin.cjs"
@ -3426,41 +3417,6 @@
"@esbuild/win32-x64": "0.27.3" "@esbuild/win32-x64": "0.27.3"
} }
}, },
"node_modules/esbuild-register": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz",
"integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==",
"dev": true,
"dependencies": {
"debug": "^4.3.4"
},
"peerDependencies": {
"esbuild": ">=0.12 <1"
}
},
"node_modules/esbuild-register/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/esbuild-register/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true
},
"node_modules/escape-html": { "node_modules/escape-html": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
@ -3623,9 +3579,9 @@
] ]
}, },
"node_modules/fastify": { "node_modules/fastify": {
"version": "5.7.4", "version": "5.8.4",
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.7.4.tgz", "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.4.tgz",
"integrity": "sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA==", "integrity": "sha512-sa42J1xylbBAYUWALSBoyXKPDUvM3OoNOibIefA+Oha57FryXKKCZarA1iDntOCWp3O35voZLuDg2mdODXtPzQ==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@ -3646,7 +3602,7 @@
"fast-json-stringify": "^6.0.0", "fast-json-stringify": "^6.0.0",
"find-my-way": "^9.0.0", "find-my-way": "^9.0.0",
"light-my-request": "^6.0.0", "light-my-request": "^6.0.0",
"pino": "^10.1.0", "pino": "^9.14.0 || ^10.1.0",
"process-warning": "^5.0.0", "process-warning": "^5.0.0",
"rfdc": "^1.3.1", "rfdc": "^1.3.1",
"secure-json-parse": "^4.0.0", "secure-json-parse": "^4.0.0",
@ -4066,20 +4022,6 @@
"url": "https://github.com/sponsors/dmonad" "url": "https://github.com/sponsors/dmonad"
} }
}, },
"node_modules/jackspeak": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz",
"integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==",
"dependencies": {
"@isaacs/cliui": "^9.0.0"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/jiti": { "node_modules/jiti": {
"version": "2.6.1", "version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
@ -4407,14 +4349,14 @@
} }
}, },
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "10.2.0", "version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.0.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w==", "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"dependencies": { "dependencies": {
"brace-expansion": "^5.0.2" "brace-expansion": "^5.0.5"
}, },
"engines": { "engines": {
"node": "20 || >=22" "node": "18 || 20 || >=22"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
@ -4542,9 +4484,9 @@
} }
}, },
"node_modules/nodemailer": { "node_modules/nodemailer": {
"version": "8.0.1", "version": "8.0.4",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz",
"integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==", "integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==",
"engines": { "engines": {
"node": ">=6.0.0" "node": ">=6.0.0"
} }
@ -4705,9 +4647,9 @@
} }
}, },
"node_modules/path-to-regexp": { "node_modules/path-to-regexp": {
"version": "0.1.12", "version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==" "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="
}, },
"node_modules/pathe": { "node_modules/pathe": {
"version": "2.0.3", "version": "2.0.3",
@ -5000,9 +4942,9 @@
} }
}, },
"node_modules/qs": { "node_modules/qs": {
"version": "6.14.1", "version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
"dependencies": { "dependencies": {
"side-channel": "^1.1.0" "side-channel": "^1.1.0"
}, },
@ -5848,10 +5790,9 @@
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==" "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="
}, },
"node_modules/yaml": { "node_modules/yaml": {
"version": "2.8.2", "version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"license": "ISC",
"bin": { "bin": {
"yaml": "bin.mjs" "yaml": "bin.mjs"
}, },

View File

@ -0,0 +1,168 @@
-- CreateEnum
CREATE TYPE "StrawPollType" AS ENUM ('SINGLE_CHOICE', 'YES_NO_ABSTAIN');
-- CreateEnum
CREATE TYPE "StrawPollStatus" AS ENUM ('DRAFT', 'ACTIVE', 'CLOSED', 'ARCHIVED');
-- CreateEnum
CREATE TYPE "StrawPollIdentityMode" AS ENUM ('ANONYMOUS', 'TOKEN_GATED', 'AUTHENTICATED', 'MIXED');
-- CreateEnum
CREATE TYPE "StrawPollResultVisibility" AS ENUM ('LIVE', 'AFTER_VOTE', 'AFTER_CLOSE', 'CREATOR_ONLY', 'PUBLIC_ALWAYS');
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "NotificationType" ADD VALUE 'poll_closed';
ALTER TYPE "NotificationType" ADD VALUE 'poll_results_available';
ALTER TYPE "NotificationType" ADD VALUE 'poll_challenge';
-- AlterEnum
ALTER TYPE "UserRole" ADD VALUE 'POLLS_ADMIN';
-- AlterTable
ALTER TABLE "site_settings" ADD COLUMN "enable_polls" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "straw_polls" (
"id" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"title" VARCHAR(200) NOT NULL,
"description" TEXT,
"type" "StrawPollType" NOT NULL,
"status" "StrawPollStatus" NOT NULL DEFAULT 'DRAFT',
"identity_mode" "StrawPollIdentityMode" NOT NULL DEFAULT 'ANONYMOUS',
"result_visibility" "StrawPollResultVisibility" NOT NULL DEFAULT 'LIVE',
"allow_comments" BOOLEAN NOT NULL DEFAULT true,
"closes_at" TIMESTAMP(3),
"close_threshold" INTEGER,
"auto_close_job_id" TEXT,
"is_private" BOOLEAN NOT NULL DEFAULT false,
"created_by_user_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "straw_polls_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "straw_poll_options" (
"id" TEXT NOT NULL,
"poll_id" TEXT NOT NULL,
"label" VARCHAR(500) NOT NULL,
"sort_order" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "straw_poll_options_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "straw_poll_votes" (
"id" TEXT NOT NULL,
"poll_id" TEXT NOT NULL,
"option_id" TEXT NOT NULL,
"user_id" TEXT,
"voter_name" VARCHAR(100),
"voter_token" TEXT,
"voter_ip" TEXT,
"contact_id" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "straw_poll_votes_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "straw_poll_comments" (
"id" TEXT NOT NULL,
"poll_id" TEXT NOT NULL,
"user_id" TEXT,
"author_name" VARCHAR(100) NOT NULL,
"content" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "straw_poll_comments_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "straw_poll_challenges" (
"id" TEXT NOT NULL,
"poll_id" TEXT NOT NULL,
"challenger_user_id" TEXT NOT NULL,
"challenged_user_id" TEXT NOT NULL,
"completed_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "straw_poll_challenges_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "straw_polls_slug_key" ON "straw_polls"("slug");
-- CreateIndex
CREATE INDEX "straw_polls_created_by_user_id_idx" ON "straw_polls"("created_by_user_id");
-- CreateIndex
CREATE INDEX "straw_polls_status_idx" ON "straw_polls"("status");
-- CreateIndex
CREATE INDEX "straw_poll_options_poll_id_idx" ON "straw_poll_options"("poll_id");
-- CreateIndex
CREATE INDEX "straw_poll_votes_poll_id_idx" ON "straw_poll_votes"("poll_id");
-- CreateIndex
CREATE INDEX "straw_poll_votes_option_id_idx" ON "straw_poll_votes"("option_id");
-- CreateIndex
CREATE UNIQUE INDEX "straw_poll_votes_poll_id_user_id_key" ON "straw_poll_votes"("poll_id", "user_id");
-- CreateIndex
CREATE UNIQUE INDEX "straw_poll_votes_poll_id_voter_token_key" ON "straw_poll_votes"("poll_id", "voter_token");
-- CreateIndex
CREATE UNIQUE INDEX "straw_poll_votes_poll_id_voter_ip_key" ON "straw_poll_votes"("poll_id", "voter_ip");
-- CreateIndex
CREATE INDEX "straw_poll_comments_poll_id_idx" ON "straw_poll_comments"("poll_id");
-- CreateIndex
CREATE UNIQUE INDEX "straw_poll_challenges_poll_id_challenger_user_id_challenged_key" ON "straw_poll_challenges"("poll_id", "challenger_user_id", "challenged_user_id");
-- AddForeignKey
ALTER TABLE "straw_polls" ADD CONSTRAINT "straw_polls_created_by_user_id_fkey" FOREIGN KEY ("created_by_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "straw_poll_options" ADD CONSTRAINT "straw_poll_options_poll_id_fkey" FOREIGN KEY ("poll_id") REFERENCES "straw_polls"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "straw_poll_votes" ADD CONSTRAINT "straw_poll_votes_poll_id_fkey" FOREIGN KEY ("poll_id") REFERENCES "straw_polls"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "straw_poll_votes" ADD CONSTRAINT "straw_poll_votes_option_id_fkey" FOREIGN KEY ("option_id") REFERENCES "straw_poll_options"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "straw_poll_votes" ADD CONSTRAINT "straw_poll_votes_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "straw_poll_votes" ADD CONSTRAINT "straw_poll_votes_contact_id_fkey" FOREIGN KEY ("contact_id") REFERENCES "contacts"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "straw_poll_comments" ADD CONSTRAINT "straw_poll_comments_poll_id_fkey" FOREIGN KEY ("poll_id") REFERENCES "straw_polls"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "straw_poll_comments" ADD CONSTRAINT "straw_poll_comments_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "straw_poll_challenges" ADD CONSTRAINT "straw_poll_challenges_poll_id_fkey" FOREIGN KEY ("poll_id") REFERENCES "straw_polls"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "straw_poll_challenges" ADD CONSTRAINT "straw_poll_challenges_challenger_user_id_fkey" FOREIGN KEY ("challenger_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "straw_poll_challenges" ADD CONSTRAINT "straw_poll_challenges_challenged_user_id_fkey" FOREIGN KEY ("challenged_user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,18 @@
-- AlterEnum: Add DISPUTED status for chargeback tracking
ALTER TYPE "OrderStatus" ADD VALUE 'DISPUTED';
-- DropForeignKey: Make paymentId optional on audit log
ALTER TABLE "payment_audit_log" DROP CONSTRAINT "payment_audit_log_payment_id_fkey";
-- AlterTable: Add orderId column, make paymentId nullable
ALTER TABLE "payment_audit_log" ADD COLUMN "order_id" TEXT,
ALTER COLUMN "payment_id" DROP NOT NULL;
-- CreateIndex
CREATE INDEX "idx_payment_audit_log_order" ON "payment_audit_log"("order_id");
-- AddForeignKey (nullable)
ALTER TABLE "payment_audit_log" ADD CONSTRAINT "payment_audit_log_payment_id_fkey" FOREIGN KEY ("payment_id") REFERENCES "payments"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "payment_audit_log" ADD CONSTRAINT "payment_audit_log_order_id_fkey" FOREIGN KEY ("order_id") REFERENCES "orders"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@ -0,0 +1,4 @@
-- Add SHIFT, MEETING, TICKETED_EVENT to CalendarItemSource enum
ALTER TYPE "CalendarItemSource" ADD VALUE IF NOT EXISTS 'SHIFT';
ALTER TYPE "CalendarItemSource" ADD VALUE IF NOT EXISTS 'MEETING';
ALTER TYPE "CalendarItemSource" ADD VALUE IF NOT EXISTS 'TICKETED_EVENT';

View File

@ -21,6 +21,7 @@ enum UserRole {
PAYMENTS_ADMIN PAYMENTS_ADMIN
EVENTS_ADMIN EVENTS_ADMIN
SOCIAL_ADMIN SOCIAL_ADMIN
POLLS_ADMIN
USER USER
TEMP TEMP
} }
@ -167,6 +168,13 @@ model User {
schedulingPollVotes SchedulingPollVote[] @relation("PollVoter") schedulingPollVotes SchedulingPollVote[] @relation("PollVoter")
schedulingPollComments SchedulingPollComment[] @relation("PollCommenter") schedulingPollComments SchedulingPollComment[] @relation("PollCommenter")
// Straw polls
strawPollsCreated StrawPoll[] @relation("StrawPollCreator")
strawPollVotes StrawPollVote[] @relation("StrawPollVoter")
strawPollComments StrawPollComment[] @relation("StrawPollCommenter")
strawPollChallengesSent StrawPollChallenge[] @relation("StrawPollChallenger")
strawPollChallengesReceived StrawPollChallenge[] @relation("StrawPollChallenged")
// Participant needs // Participant needs
participantNeeds ParticipantNeeds? @relation("UserParticipantNeeds") participantNeeds ParticipantNeeds? @relation("UserParticipantNeeds")
@ -962,6 +970,7 @@ model SiteSettings {
enableMeetingPlanner Boolean @default(false) @map("enable_meeting_planner") enableMeetingPlanner Boolean @default(false) @map("enable_meeting_planner")
enableTicketedEvents Boolean @default(false) @map("enable_ticketed_events") enableTicketedEvents Boolean @default(false) @map("enable_ticketed_events")
enableSocialCalendar Boolean @default(false) @map("enable_social_calendar") enableSocialCalendar Boolean @default(false) @map("enable_social_calendar")
enablePolls Boolean @default(false) @map("enable_polls")
enableDocsCollaboration Boolean @default(false) @map("enable_docs_collaboration") enableDocsCollaboration Boolean @default(false) @map("enable_docs_collaboration")
requireEventApproval Boolean @default(true) @map("require_event_approval") requireEventApproval Boolean @default(true) @map("require_event_approval")
autoSyncPeopleToMap Boolean @default(false) @map("auto_sync_people_to_map") autoSyncPeopleToMap Boolean @default(false) @map("auto_sync_people_to_map")
@ -1528,6 +1537,7 @@ enum OrderStatus {
COMPLETED COMPLETED
FAILED FAILED
REFUNDED REFUNDED
DISPUTED
} }
enum NotificationType { enum NotificationType {
@ -1552,6 +1562,10 @@ enum NotificationType {
shift_cancelled shift_cancelled
canvass_session_summary canvass_session_summary
reengagement reengagement
// Straw poll notification types
poll_closed
poll_results_available
poll_challenge
} }
// ============================================================================ // ============================================================================
@ -3427,7 +3441,8 @@ model Payment {
model PaymentAuditLog { model PaymentAuditLog {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
paymentId Int @map("payment_id") paymentId Int? @map("payment_id")
orderId String? @map("order_id")
action String action String
oldStatus String? @map("old_status") oldStatus String? @map("old_status")
newStatus String? @map("new_status") newStatus String? @map("new_status")
@ -3436,10 +3451,12 @@ model PaymentAuditLog {
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
// Relations // Relations
payment Payment @relation(fields: [paymentId], references: [id]) payment Payment? @relation(fields: [paymentId], references: [id])
order Order? @relation(fields: [orderId], references: [id])
user User? @relation("PaymentAuditUser", fields: [userId], references: [id]) user User? @relation("PaymentAuditUser", fields: [userId], references: [id])
@@index([paymentId], map: "idx_payment_audit_log_payment") @@index([paymentId], map: "idx_payment_audit_log_payment")
@@index([orderId], map: "idx_payment_audit_log_order")
@@index([action], map: "idx_payment_audit_log_action") @@index([action], map: "idx_payment_audit_log_action")
@@index([createdAt], map: "idx_payment_audit_log_created") @@index([createdAt], map: "idx_payment_audit_log_created")
@@map("payment_audit_log") @@map("payment_audit_log")
@ -3505,6 +3522,7 @@ model Order {
influenceCampaignId String? @map("influence_campaign_id") influenceCampaignId String? @map("influence_campaign_id")
influenceCampaign Campaign? @relation("CampaignDonations", fields: [influenceCampaignId], references: [id], onDelete: SetNull) influenceCampaign Campaign? @relation("CampaignDonations", fields: [influenceCampaignId], references: [id], onDelete: SetNull)
tickets Ticket[] @relation("TicketOrder") tickets Ticket[] @relation("TicketOrder")
auditLogs PaymentAuditLog[]
@@index([userId], map: "idx_orders_user") @@index([userId], map: "idx_orders_user")
@@index([productId], map: "idx_orders_product") @@index([productId], map: "idx_orders_product")
@ -4274,6 +4292,7 @@ model Contact {
activities ContactActivity[] activities ContactActivity[]
smsConversations SmsConversation[] @relation("ContactSmsConversations") smsConversations SmsConversation[] @relation("ContactSmsConversations")
pollVotes SchedulingPollVote[] @relation("PollVoteContact") pollVotes SchedulingPollVote[] @relation("PollVoteContact")
strawPollVotes StrawPollVote[] @relation("StrawPollVoteContact")
participantNeeds ParticipantNeeds? @relation("ContactParticipantNeeds") participantNeeds ParticipantNeeds? @relation("ContactParticipantNeeds")
@@index([email]) @@index([email])
@ -4949,6 +4968,9 @@ enum CalendarItemSource {
MANUAL MANUAL
ICS_FEED ICS_FEED
POLL POLL
SHIFT
MEETING
TICKETED_EVENT
} }
enum CalendarRecurrenceFrequency { enum CalendarRecurrenceFrequency {
@ -5344,3 +5366,132 @@ model ActionItem {
@@index([dueDate]) @@index([dueDate])
@@map("action_items") @@map("action_items")
} }
// ============================================================================
// STRAW POLLS
// ============================================================================
enum StrawPollType {
SINGLE_CHOICE
YES_NO_ABSTAIN
}
enum StrawPollStatus {
DRAFT
ACTIVE
CLOSED
ARCHIVED
}
enum StrawPollIdentityMode {
ANONYMOUS
TOKEN_GATED
AUTHENTICATED
MIXED
}
enum StrawPollResultVisibility {
LIVE
AFTER_VOTE
AFTER_CLOSE
CREATOR_ONLY
PUBLIC_ALWAYS
}
model StrawPoll {
id String @id @default(cuid())
slug String @unique
title String @db.VarChar(200)
description String? @db.Text
type StrawPollType
status StrawPollStatus @default(DRAFT)
identityMode StrawPollIdentityMode @default(ANONYMOUS) @map("identity_mode")
resultVisibility StrawPollResultVisibility @default(LIVE) @map("result_visibility")
allowComments Boolean @default(true) @map("allow_comments")
closesAt DateTime? @map("closes_at")
closeThreshold Int? @map("close_threshold")
autoCloseJobId String? @map("auto_close_job_id")
isPrivate Boolean @default(false) @map("is_private")
createdByUserId String @map("created_by_user_id")
createdBy User @relation("StrawPollCreator", fields: [createdByUserId], references: [id])
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
options StrawPollOption[]
votes StrawPollVote[]
comments StrawPollComment[]
challenges StrawPollChallenge[]
@@index([createdByUserId])
@@index([status])
@@map("straw_polls")
}
model StrawPollOption {
id String @id @default(cuid())
pollId String @map("poll_id")
poll StrawPoll @relation(fields: [pollId], references: [id], onDelete: Cascade)
label String @db.VarChar(500)
sortOrder Int @default(0) @map("sort_order")
votes StrawPollVote[]
@@index([pollId])
@@map("straw_poll_options")
}
model StrawPollVote {
id String @id @default(cuid())
pollId String @map("poll_id")
poll StrawPoll @relation(fields: [pollId], references: [id], onDelete: Cascade)
optionId String @map("option_id")
option StrawPollOption @relation(fields: [optionId], references: [id], onDelete: Cascade)
userId String? @map("user_id")
user User? @relation("StrawPollVoter", fields: [userId], references: [id], onDelete: SetNull)
voterName String? @db.VarChar(100) @map("voter_name")
voterToken String? @map("voter_token")
voterIp String? @map("voter_ip")
contactId String? @map("contact_id")
contact Contact? @relation("StrawPollVoteContact", fields: [contactId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@unique([pollId, userId])
@@unique([pollId, voterToken])
@@unique([pollId, voterIp])
@@index([pollId])
@@index([optionId])
@@map("straw_poll_votes")
}
model StrawPollComment {
id String @id @default(cuid())
pollId String @map("poll_id")
poll StrawPoll @relation(fields: [pollId], references: [id], onDelete: Cascade)
userId String? @map("user_id")
user User? @relation("StrawPollCommenter", fields: [userId], references: [id], onDelete: SetNull)
authorName String @db.VarChar(100) @map("author_name")
content String @db.Text
createdAt DateTime @default(now()) @map("created_at")
@@index([pollId])
@@map("straw_poll_comments")
}
model StrawPollChallenge {
id String @id @default(cuid())
pollId String @map("poll_id")
poll StrawPoll @relation(fields: [pollId], references: [id], onDelete: Cascade)
challengerUserId String @map("challenger_user_id")
challenger User @relation("StrawPollChallenger", fields: [challengerUserId], references: [id])
challengedUserId String @map("challenged_user_id")
challenged User @relation("StrawPollChallenged", fields: [challengedUserId], references: [id])
completedAt DateTime? @map("completed_at")
createdAt DateTime @default(now()) @map("created_at")
@@unique([pollId, challengerUserId, challengedUserId])
@@map("straw_poll_challenges")
}

View File

@ -466,6 +466,32 @@ async function main() {
title: 'Vote on a Meeting Time', title: 'Vote on a Meeting Time',
}, },
}, },
{
id: 'default-straw-poll-inline',
type: 'straw-poll-inline',
label: 'Straw Poll (Inline)',
category: 'Influence',
sortOrder: 18,
schema: {
pollSlug: { type: 'string', label: 'Poll Slug', required: true },
},
defaults: {
pollSlug: '',
},
},
{
id: 'default-straw-poll-card',
type: 'straw-poll-card',
label: 'Straw Poll (Card)',
category: 'Influence',
sortOrder: 19,
schema: {
pollSlug: { type: 'string', label: 'Poll Slug', required: true },
},
defaults: {
pollSlug: '',
},
},
]; ];
for (const block of defaultBlocks) { for (const block of defaultBlocks) {

View File

@ -38,6 +38,11 @@ const envSchema = z.object({
// Encryption (for DB-stored secrets like SMTP password — required for all environments) // Encryption (for DB-stored secrets like SMTP password — required for all environments)
ENCRYPTION_KEY: z.string().min(32, 'ENCRYPTION_KEY must be at least 32 characters'), ENCRYPTION_KEY: z.string().min(32, 'ENCRYPTION_KEY must be at least 32 characters'),
// Gitea SSO cookie signing secret (falls back to JWT_ACCESS_SECRET if empty)
GITEA_SSO_SECRET: z.string().default(''),
// Salt for deriving deterministic service passwords (Gitea, Rocket.Chat — falls back to JWT_ACCESS_SECRET if empty)
SERVICE_PASSWORD_SALT: z.string().default(''),
// Initial Super Admin (auto-created during database seeding) // Initial Super Admin (auto-created during database seeding)
INITIAL_ADMIN_EMAIL: z.string().email().default('admin@cmlite.org'), INITIAL_ADMIN_EMAIL: z.string().email().default('admin@cmlite.org'),
INITIAL_ADMIN_PASSWORD: z.string().min(12).default('REQUIRED_STRONG_PASSWORD_CHANGE_THIS') INITIAL_ADMIN_PASSWORD: z.string().min(12).default('REQUIRED_STRONG_PASSWORD_CHANGE_THIS')

View File

@ -156,6 +156,23 @@ export const adTrackingRateLimit = rateLimit({
}, },
}); });
export const smsSendRateLimit = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 10,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
prefix: 'rl:sms-send:',
}),
message: {
error: {
message: 'Too many SMS send requests, please try again later',
code: 'SMS_SEND_RATE_LIMIT_EXCEEDED',
},
},
});
export const quickJoinRateLimit = rateLimit({ export const quickJoinRateLimit = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour windowMs: 60 * 60 * 1000, // 1 hour
max: 10, max: 10,
@ -173,6 +190,23 @@ export const quickJoinRateLimit = rateLimit({
}, },
}); });
export const paymentCheckoutRateLimit = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 10, // 10 checkout sessions per hour per IP
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
prefix: 'rl:payment-checkout:',
}),
message: {
error: {
message: 'Too many payment requests, please try again later',
code: 'PAYMENT_CHECKOUT_RATE_LIMIT_EXCEEDED',
},
},
});
export const authRateLimit = rateLimit({ export const authRateLimit = rateLimit({
windowMs: 15 * 60 * 1000, windowMs: 15 * 60 * 1000,
max: 10, // Reduced from 20 to prevent brute force attacks max: 10, // Reduced from 20 to prevent brute force attacks

View File

@ -1,6 +1,7 @@
import { Router, Request, Response, NextFunction } from 'express'; import { Router, Request, Response, NextFunction } from 'express';
import { z } from 'zod'; import { z } from 'zod';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { UserRole, UserStatus } from '@prisma/client'; import { UserRole, UserStatus } from '@prisma/client';
import { authService } from './auth.service'; import { authService } from './auth.service';
import { loginSchema, registerSchema, refreshSchema } from './auth.schemas'; import { loginSchema, registerSchema, refreshSchema } from './auth.schemas';
@ -22,6 +23,9 @@ const router = Router();
const REFRESH_COOKIE_NAME = 'cml_refresh'; const REFRESH_COOKIE_NAME = 'cml_refresh';
const REFRESH_COOKIE_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days in ms const REFRESH_COOKIE_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days in ms
const SESSION_COOKIE_NAME = 'cml_session';
const SESSION_COOKIE_MAX_AGE = 30 * 60 * 1000; // 30 min buffer (JWT inside enforces 15min expiry)
/** Set the refresh token as an httpOnly cookie. /** Set the refresh token as an httpOnly cookie.
* Uses req.secure (respects trust proxy + X-Forwarded-Proto) to determine * Uses req.secure (respects trust proxy + X-Forwarded-Proto) to determine
* the Secure flag, so it works correctly over both HTTP (dev) and HTTPS (tunnel). */ * the Secure flag, so it works correctly over both HTTP (dev) and HTTPS (tunnel). */
@ -45,6 +49,53 @@ function clearRefreshCookie(req: Request, res: Response) {
}); });
} }
/** Cookie options for the SSO session cookie (domain-wide for Gitea reverse proxy auth) */
function sessionCookieOptions(req: Request) {
const isSecure = req.secure;
const domain = env.DOMAIN;
// Use domain-wide cookie for production (subdomains); omit for localhost dev
const hasDomain = domain && !domain.includes('localhost') && !domain.match(/^\d/);
return {
httpOnly: true,
secure: isSecure,
sameSite: 'lax' as const,
path: '/',
maxAge: SESSION_COOKIE_MAX_AGE,
...(hasDomain ? { domain: `.${domain}` } : {}),
};
}
/** Set the SSO session cookie for Gitea reverse proxy auth (fire-and-forget).
* Only sets the cookie if the user has a provisioned Gitea account. */
async function setSessionCookie(req: Request, res: Response, userId: string) {
try {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { permissions: true },
});
const permissions = (user?.permissions as Record<string, unknown>) || {};
const giteaUser = permissions._giteaUsername as string | undefined;
if (!giteaUser) return; // Not provisioned — skip
const ssoSecret = env.GITEA_SSO_SECRET || env.JWT_ACCESS_SECRET;
const token = jwt.sign(
{ sub: userId, giteaUser },
ssoSecret,
{ algorithm: 'HS256', expiresIn: '15m' },
);
res.cookie(SESSION_COOKIE_NAME, token, sessionCookieOptions(req));
} catch (err) {
logger.debug('Failed to set SSO session cookie:', err);
}
}
/** Clear the SSO session cookie */
function clearSessionCookie(req: Request, res: Response) {
const opts = sessionCookieOptions(req);
delete (opts as Record<string, unknown>).maxAge;
res.clearCookie(SESSION_COOKIE_NAME, opts);
}
// POST /api/auth/login // POST /api/auth/login
router.post( router.post(
'/login', '/login',
@ -55,6 +106,8 @@ router.post(
const result = await authService.login(req.body.email, req.body.password); const result = await authService.login(req.body.email, req.body.password);
// Set refresh token as httpOnly cookie (not in response body) // Set refresh token as httpOnly cookie (not in response body)
setRefreshCookie(req, res, result.refreshToken); setRefreshCookie(req, res, result.refreshToken);
// Set SSO session cookie for Gitea reverse proxy auth
await setSessionCookie(req, res, result.user.id);
const { refreshToken: _, ...responseWithoutRefresh } = result; const { refreshToken: _, ...responseWithoutRefresh } = result;
res.json(responseWithoutRefresh); res.json(responseWithoutRefresh);
} catch (err) { } catch (err) {
@ -281,6 +334,10 @@ router.post(
const result = await authService.refreshTokens(refreshToken); const result = await authService.refreshTokens(refreshToken);
// Set new refresh token as httpOnly cookie // Set new refresh token as httpOnly cookie
setRefreshCookie(req, res, result.refreshToken); setRefreshCookie(req, res, result.refreshToken);
// Renew SSO session cookie for Gitea reverse proxy auth
if (result.user?.id) {
await setSessionCookie(req, res, result.user.id);
}
const { refreshToken: _, ...responseWithoutRefresh } = result; const { refreshToken: _, ...responseWithoutRefresh } = result;
res.json(responseWithoutRefresh); res.json(responseWithoutRefresh);
} catch (err) { } catch (err) {
@ -302,9 +359,11 @@ router.post(
await authService.logout(refreshToken); await authService.logout(refreshToken);
} }
clearRefreshCookie(req, res); clearRefreshCookie(req, res);
clearSessionCookie(req, res);
res.json({ message: 'Logged out' }); res.json({ message: 'Logged out' });
} catch (err) { } catch (err) {
clearRefreshCookie(req, res); clearRefreshCookie(req, res);
clearSessionCookie(req, res);
next(err); next(err);
} }
} }

View File

@ -0,0 +1,50 @@
import { Router, Request, Response } from 'express';
import jwt from 'jsonwebtoken';
import { env } from '../../config/env';
import { logger } from '../../utils/logger';
const router = Router();
interface SsoPayload {
sub: string;
giteaUser: string;
}
/**
* GET /api/auth/gitea-sso-validate
*
* Called by nginx auth_request to validate the cml_session cookie.
* Always returns 200 never blocks requests.
* Sets X-Gitea-User header when the session is valid and the user
* has a provisioned Gitea account. Empty header = no SSO (Gitea
* shows its own login page).
*/
router.get('/gitea-sso-validate', (req: Request, res: Response) => {
const token = req.cookies?.cml_session;
if (!token) {
res.setHeader('X-Gitea-User', '');
res.status(200).end();
return;
}
try {
const ssoSecret = env.GITEA_SSO_SECRET || env.JWT_ACCESS_SECRET;
const payload = jwt.verify(token, ssoSecret, {
algorithms: ['HS256'],
}) as SsoPayload;
if (payload.giteaUser) {
res.setHeader('X-Gitea-User', payload.giteaUser);
} else {
res.setHeader('X-Gitea-User', '');
}
} catch {
// Expired or invalid JWT — no SSO, graceful fallback
res.setHeader('X-Gitea-User', '');
}
res.status(200).end();
});
export { router as giteaSsoRouter };

View File

@ -925,7 +925,7 @@ export const sharedCalendarService = {
include: { include: {
members: { members: {
where: { status: SharedViewMemberStatus.ACCEPTED }, where: { status: SharedViewMemberStatus.ACCEPTED },
include: { user: { select: { id: true, name: true, email: true } } }, include: { user: { select: { id: true, name: true } } },
}, },
}, },
}); });
@ -972,7 +972,7 @@ export const sharedCalendarService = {
orderBy: [{ date: 'asc' }, { startTime: 'asc' }], orderBy: [{ date: 'asc' }, { startTime: 'asc' }],
}); });
const memberName = member.user.name || member.user.email; const memberName = member.user.name || 'Member';
for (const item of items) { for (const item of items) {
if (item.visibility === CalendarVisibility.PRIVATE || item.visibility === CalendarVisibility.FRIENDS) continue; if (item.visibility === CalendarVisibility.PRIVATE || item.visibility === CalendarVisibility.FRIENDS) continue;
allItems.push({ allItems.push({
@ -999,7 +999,7 @@ export const sharedCalendarService = {
const sysLayers = filteredLayers.filter(l => l.layerType === CalendarLayerType.SYSTEM); const sysLayers = filteredLayers.filter(l => l.layerType === CalendarLayerType.SYSTEM);
for (const sysLayer of sysLayers) { for (const sysLayer of sysLayers) {
const sysItems = await this.getSystemLayerItems(member.userId, sysLayer, start, end); const sysItems = await this.getSystemLayerItems(member.userId, sysLayer, start, end);
const memberName = member.user.name || member.user.email; const memberName = member.user.name || 'Member';
for (const si of sysItems) { for (const si of sysItems) {
allItems.push({ allItems.push({
...si, ...si,

View File

@ -1,5 +1,6 @@
import { Router, Request, Response, NextFunction } from 'express'; import { Router, Request, Response, NextFunction } from 'express';
import { homepageService } from './homepage.service'; import { homepageService } from './homepage.service';
import { homepageStats } from '../../services/event-listeners/homepage-stats.listener';
const router = Router(); const router = Router();
@ -13,4 +14,17 @@ router.get('/', async (_req: Request, res: Response, next: NextFunction) => {
} }
}); });
// GET /api/homepage/live-stats — Real-time EventBus-driven counters (no auth)
router.get('/live-stats', async (_req: Request, res: Response, next: NextFunction) => {
try {
const counters = await homepageStats.getCounters();
const recentSignups = await homepageStats.getRecent('signups', 5);
const recentDonations = await homepageStats.getRecent('donations', 5);
const recentResponses = await homepageStats.getRecent('responses', 5);
res.json({ counters, recent: { signups: recentSignups, donations: recentDonations, responses: recentResponses } });
} catch (err) {
next(err);
}
});
export { router as homepageRouter }; export { router as homepageRouter };

View File

@ -5,6 +5,7 @@ import { validate } from '../../../middleware/validate';
import { authenticate } from '../../../middleware/auth.middleware'; import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware'; import { requireRole } from '../../../middleware/rbac.middleware';
import { INFLUENCE_ROLES } from '../../../utils/roles'; import { INFLUENCE_ROLES } from '../../../utils/roles';
import { eventBus } from '../../../services/event-bus.service';
const router = Router(); const router = Router();
@ -45,7 +46,22 @@ router.patch(
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
const id = req.params.id as string; const id = req.params.id as string;
const before = await campaignsService.findById(id);
const campaign = await campaignsService.moderateCampaign(id, req.body, req.user!); const campaign = await campaignsService.moderateCampaign(id, req.body, req.user!);
eventBus.publish('campaign.status.changed', {
campaignId: campaign.id,
title: campaign.title,
slug: campaign.slug,
oldStatus: before.moderationStatus ?? 'PENDING',
newStatus: campaign.moderationStatus ?? 'PENDING',
});
if (campaign.status === 'ACTIVE' && before.status !== 'ACTIVE') {
eventBus.publish('campaign.published', {
campaignId: campaign.id,
title: campaign.title,
slug: campaign.slug,
});
}
res.json(campaign); res.json(campaign);
} catch (err) { } catch (err) {
next(err); next(err);

View File

@ -5,6 +5,7 @@ import { validate } from '../../../middleware/validate';
import { authenticate } from '../../../middleware/auth.middleware'; import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware'; import { requireRole } from '../../../middleware/rbac.middleware';
import { INFLUENCE_ROLES } from '../../../utils/roles'; import { INFLUENCE_ROLES } from '../../../utils/roles';
import { eventBus } from '../../../services/event-bus.service';
const router = Router(); const router = Router();
@ -47,6 +48,12 @@ router.post(
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
const campaign = await campaignsService.create(req.body, req.user!); const campaign = await campaignsService.create(req.body, req.user!);
eventBus.publish('campaign.created', {
campaignId: campaign.id,
title: campaign.title,
slug: campaign.slug,
createdByUserId: campaign.createdByUserId!,
});
res.status(201).json(campaign); res.status(201).json(campaign);
} catch (err) { } catch (err) {
next(err); next(err);
@ -62,6 +69,12 @@ router.put(
try { try {
const id = req.params.id as string; const id = req.params.id as string;
const campaign = await campaignsService.update(id, req.body); const campaign = await campaignsService.update(id, req.body);
eventBus.publish('campaign.updated', {
campaignId: campaign.id,
title: campaign.title,
slug: campaign.slug,
changes: Object.keys(req.body),
});
res.json(campaign); res.json(campaign);
} catch (err) { } catch (err) {
next(err); next(err);
@ -75,7 +88,13 @@ router.delete(
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
const id = req.params.id as string; const id = req.params.id as string;
const campaign = await campaignsService.findById(id);
await campaignsService.delete(id); await campaignsService.delete(id);
eventBus.publish('campaign.deleted', {
campaignId: campaign.id,
title: campaign.title,
slug: campaign.slug,
});
res.status(204).send(); res.status(204).send();
} catch (err) { } catch (err) {
next(err); next(err);

View File

@ -8,7 +8,7 @@ import { getAdminEmailsByRole, isNotificationEnabled } from '../../../services/n
import { env } from '../../../config/env'; import { env } from '../../../config/env';
import { logger } from '../../../utils/logger'; import { logger } from '../../../utils/logger';
import { recordResponseSubmission } from '../../../utils/metrics'; import { recordResponseSubmission } from '../../../utils/metrics';
import { rocketchatWebhookService } from '../../../services/rocketchat-webhook.service'; import { eventBus } from '../../../services/event-bus.service';
import type { import type {
SubmitResponseInput, SubmitResponseInput,
ListPublicResponsesInput, ListPublicResponsesInput,
@ -102,11 +102,14 @@ export const responsesService = {
logger.error('Failed to enqueue response submitted notification:', err); logger.error('Failed to enqueue response submitted notification:', err);
} }
// Notify Rocket.Chat // Publish response submitted event
rocketchatWebhookService.onCampaignResponseSubmitted({ eventBus.publish('response.submitted', {
responseId: response.id,
campaignId: campaign.id,
campaignTitle: campaign.title, campaignTitle: campaign.title,
representativeName: data.representativeName, representativeName: data.representativeName,
}).catch(() => {}); userEmail: data.submittedByEmail,
});
return { return {
id: response.id, id: response.id,

View File

@ -9,6 +9,7 @@ import { prisma } from '../../config/database';
import { siteSettingsService } from '../settings/settings.service'; import { siteSettingsService } from '../settings/settings.service';
import { isServiceOnline } from '../../utils/health-check'; import { isServiceOnline } from '../../utils/health-check';
import { generateSlug, generateModeratorToken } from './jitsi.utils'; import { generateSlug, generateModeratorToken } from './jitsi.utils';
import { eventBus } from '../../services/event-bus.service';
const router = Router(); const router = Router();
@ -172,6 +173,14 @@ router.post(
}, },
}); });
eventBus.publish('meeting.created', {
meetingId: meeting.id,
title: meeting.title,
scheduledAt: meeting.startTime?.toISOString() ?? new Date().toISOString(),
jitsiRoomName: meeting.jitsiRoom,
createdByUserId: meeting.createdByUserId,
});
res.status(201).json(meeting); res.status(201).json(meeting);
} catch (err) { } catch (err) {
logger.error('Create meeting failed:', err); logger.error('Create meeting failed:', err);
@ -226,6 +235,12 @@ router.delete(
} }
await prisma.meeting.delete({ where: { id: meetingId } }); await prisma.meeting.delete({ where: { id: meetingId } });
eventBus.publish('meeting.deleted', {
meetingId: meeting.id,
title: meeting.title,
});
res.json({ success: true }); res.json({ success: true });
} catch (err) { } catch (err) {
logger.error('Delete meeting failed:', err); logger.error('Delete meeting failed:', err);

View File

@ -2,6 +2,7 @@ import { Router, Request, Response, NextFunction } from 'express';
import { prisma } from '../../config/database'; import { prisma } from '../../config/database';
import { env } from '../../config/env'; import { env } from '../../config/env';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
import { eventBus } from '../../services/event-bus.service';
const router = Router(); const router = Router();
@ -32,6 +33,13 @@ router.post(
return; return;
} }
// Publish unsubscribe event to EventBus
eventBus.publish('listmonk.unsubscribed', {
subscriberEmail: email,
listId: event?.data?.list?.id ?? 0,
listName: event?.data?.list?.name ?? '',
});
// Store opt-out flag in user's permissions JSON field // Store opt-out flag in user's permissions JSON field
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { email }, where: { email },
@ -58,6 +66,56 @@ router.post(
return; return;
} }
// Email open event
if (eventType === 'campaign.view') {
const email = event?.data?.subscriber?.email;
const campaignId = event?.data?.campaign?.id;
const campaignName = event?.data?.campaign?.name;
if (email && campaignId) {
eventBus.publish('listmonk.email.opened', {
subscriberEmail: email,
campaignId,
campaignName: campaignName ?? '',
});
}
res.json({ ok: true, action: 'published', eventType });
return;
}
// Link click event
if (eventType === 'campaign.link_click') {
const email = event?.data?.subscriber?.email;
const campaignId = event?.data?.campaign?.id;
const campaignName = event?.data?.campaign?.name;
const url = event?.data?.url;
if (email && campaignId) {
eventBus.publish('listmonk.email.clicked', {
subscriberEmail: email,
campaignId,
campaignName: campaignName ?? '',
url: url ?? '',
});
}
res.json({ ok: true, action: 'published', eventType });
return;
}
// Bounce event
if (eventType === 'subscriber.bounced') {
const email = event?.data?.subscriber?.email;
const campaignId = event?.data?.campaign?.id;
const bounceType = event?.data?.bounce_type ?? 'unknown';
if (email) {
eventBus.publish('listmonk.email.bounced', {
subscriberEmail: email,
campaignId: campaignId ?? 0,
bounceType,
});
}
res.json({ ok: true, action: 'published', eventType });
return;
}
// Unknown event type — acknowledge but don't process // Unknown event type — acknowledge but don't process
logger.debug(`Listmonk webhook: unhandled event type "${eventType}"`); logger.debug(`Listmonk webhook: unhandled event type "${eventType}"`);
res.json({ ok: true, action: 'ignored', eventType }); res.json({ ok: true, action: 'ignored', eventType });

View File

@ -11,8 +11,7 @@ import { recordCanvassVisit, setActiveCanvassSessions } from '../../../utils/met
import { notificationQueueService } from '../../../services/notification-queue.service'; import { notificationQueueService } from '../../../services/notification-queue.service';
import { getAdminEmailsByRole, isNotificationEnabled } from '../../../services/notification.helper'; import { getAdminEmailsByRole, isNotificationEnabled } from '../../../services/notification.helper';
import { env } from '../../../config/env'; import { env } from '../../../config/env';
import { rocketchatWebhookService } from '../../../services/rocketchat-webhook.service'; import { eventBus } from '../../../services/event-bus.service';
import { listmonkEventSyncService } from '../../../services/listmonk-event-sync.service';
import { achievementsService } from '../../social/achievements.service'; import { achievementsService } from '../../social/achievements.service';
import type { import type {
RecordVisitInput, RecordVisitInput,
@ -254,20 +253,6 @@ export const canvassService = {
// Recalculate cut completion percentage // Recalculate cut completion percentage
await this.recalculateCutCompletion(session.cutId); 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 // Notification: volunteer session summary
try { try {
if (await isNotificationEnabled('notifyVolunteerSessionSummary')) { if (await isNotificationEnabled('notifyVolunteerSessionSummary')) {
@ -315,7 +300,7 @@ export const canvassService = {
logger.error('Failed to enqueue session summary notification:', err); logger.error('Failed to enqueue session summary notification:', err);
} }
// Listmonk event sync — add canvasser to subscribers // Publish canvass session completed event (consumed by RC, Listmonk, etc.)
try { try {
const [syncUser, syncCut, syncVisitCount, syncOutcomes] = await Promise.all([ const [syncUser, syncCut, syncVisitCount, syncOutcomes] = await Promise.all([
prisma.user.findUnique({ where: { id: userId }, select: { email: true, name: true } }), prisma.user.findUnique({ where: { id: userId }, select: { email: true, name: true } }),
@ -328,13 +313,15 @@ export const canvassService = {
for (const row of syncOutcomes) { for (const row of syncOutcomes) {
outcomes[row.outcome] = row._count; outcomes[row.outcome] = row._count;
} }
listmonkEventSyncService.onCanvassSessionCompleted({ eventBus.publish('canvass.session.completed', {
email: syncUser.email, sessionId,
name: syncUser.name || syncUser.email, userId,
userName: syncUser.name || syncUser.email,
userEmail: syncUser.email,
cutName: syncCut?.name || 'Unknown', cutName: syncCut?.name || 'Unknown',
visitCount: syncVisitCount, visitCount: syncVisitCount,
outcomes, outcomes,
}).catch(() => {}); });
} }
} catch { /* non-critical */ } } catch { /* non-critical */ }
@ -650,16 +637,16 @@ export const canvassService = {
include: { location: { select: { address: true } } }, include: { location: { select: { address: true } } },
}); });
// Sync support level change to Listmonk (fire-and-forget) // Publish address updated event (consumed by Listmonk, etc.)
if (updatedAddress.email) { if (updatedAddress.email) {
const name = [updatedAddress.firstName, updatedAddress.lastName].filter(Boolean).join(' '); const name = [updatedAddress.firstName, updatedAddress.lastName].filter(Boolean).join(' ');
listmonkEventSyncService.onAddressUpdated({ eventBus.publish('contact.address.updated', {
email: updatedAddress.email, email: updatedAddress.email,
name, name,
supportLevel: updatedAddress.supportLevel, supportLevel: updatedAddress.supportLevel,
sign: updatedAddress.sign, sign: updatedAddress.sign,
address: updatedAddress.location.address, address: updatedAddress.location.address,
}).catch(() => {}); });
} }
} }

View File

@ -8,9 +8,7 @@ import { getAdminEmailsByRole, isNotificationEnabled } from '../../../services/n
import { env } from '../../../config/env'; import { env } from '../../../config/env';
import { logger } from '../../../utils/logger'; import { logger } from '../../../utils/logger';
import { recordShiftSignup } from '../../../utils/metrics'; import { recordShiftSignup } from '../../../utils/metrics';
import { rocketchatWebhookService } from '../../../services/rocketchat-webhook.service'; import { eventBus } from '../../../services/event-bus.service';
import { listmonkEventSyncService } from '../../../services/listmonk-event-sync.service';
import { gancioClient } from '../../../services/gancio.client';
import { unifiedCalendarService } from '../../events/unified-calendar.service'; import { unifiedCalendarService } from '../../events/unified-calendar.service';
import { groupService } from '../../social/group.service'; import { groupService } from '../../social/group.service';
import { achievementsService } from '../../social/achievements.service'; import { achievementsService } from '../../social/achievements.service';
@ -138,26 +136,17 @@ export const shiftsService = {
}, },
}); });
// Gancio event sync (fire-and-forget) // Publish shift.created event (listeners: Gancio, Calendar, n8n)
if (gancioClient.enabled) { eventBus.publish('shift.created', {
gancioClient.createEvent({ shiftId: shift.id,
title: shift.title, title: shift.title,
description: shift.description, date: new Date(shift.date).toISOString().split('T')[0],
location: shift.location, startTime: shift.startTime,
date: shift.date, endTime: shift.endTime,
startTime: shift.startTime, cutId: shift.cutId,
endTime: shift.endTime, cutName: null,
}).then(async (eventId) => { createdByUserId: userId,
if (eventId) { });
await prisma.shift.update({
where: { id: shift.id },
data: { gancioEventId: eventId },
});
}
}).catch((err) => {
logger.warn('Gancio sync on shift create failed:', err);
});
}
// Bust unified calendar cache // Bust unified calendar cache
unifiedCalendarService.bustCache().catch(() => {}); unifiedCalendarService.bustCache().catch(() => {});
@ -191,19 +180,17 @@ export const shiftsService = {
data: updateData, data: updateData,
}); });
// Gancio event sync (fire-and-forget) // Publish shift.updated event (listeners: Gancio, Calendar, n8n)
if (gancioClient.enabled && shift.gancioEventId) { eventBus.publish('shift.updated', {
gancioClient.updateEvent(shift.gancioEventId, { shiftId: shift.id,
title: shift.title, title: shift.title,
description: shift.description, date: new Date(shift.date).toISOString().split('T')[0],
location: shift.location, startTime: shift.startTime,
date: shift.date, endTime: shift.endTime,
startTime: shift.startTime, cutId: shift.cutId,
endTime: shift.endTime, cutName: null,
}).catch((err) => { changes: Object.keys(data),
logger.warn('Gancio sync on shift update failed:', err); });
});
}
// Bust unified calendar cache // Bust unified calendar cache
unifiedCalendarService.bustCache().catch(() => {}); unifiedCalendarService.bustCache().catch(() => {});
@ -217,12 +204,12 @@ export const shiftsService = {
throw new AppError(404, 'Shift not found', 'SHIFT_NOT_FOUND'); throw new AppError(404, 'Shift not found', 'SHIFT_NOT_FOUND');
} }
// Delete Gancio event before deleting shift (fire-and-forget) // Publish shift.deleted event (listeners: Gancio, Calendar, n8n)
if (gancioClient.enabled && existing.gancioEventId) { eventBus.publish('shift.deleted', {
gancioClient.deleteEvent(existing.gancioEventId).catch((err) => { shiftId: id,
logger.warn('Gancio sync on shift delete failed:', err); title: existing.title,
}); date: new Date(existing.date).toISOString().split('T')[0],
} });
// Delete associated meeting if exists // Delete associated meeting if exists
if (existing.meetingId) { if (existing.meetingId) {
@ -359,13 +346,17 @@ export const shiftsService = {
}), }),
]); ]);
// Listmonk event sync // Publish shift.signup.created event (listeners: Listmonk, RC, CRM, n8n)
listmonkEventSyncService.onShiftSignup({ eventBus.publish('shift.signup.created', {
email: data.userEmail, shiftId,
name: data.userName || data.userEmail,
shiftTitle: shift.title, shiftTitle: shift.title,
shiftDate: new Date(shift.date).toISOString().split('T')[0], shiftDate: new Date(shift.date).toISOString().split('T')[0],
}).catch(() => {}); userName: data.userName || data.userEmail,
userEmail: data.userEmail,
userId: user?.id ?? null,
cutName: null,
signupType: 'admin',
});
// Social group sync (fire-and-forget) // Social group sync (fire-and-forget)
groupService.syncShiftTeam(shiftId).catch(() => {}); groupService.syncShiftTeam(shiftId).catch(() => {});
@ -551,14 +542,6 @@ export const shiftsService = {
}).catch(err => logger.error('SMS signup confirmation failed:', err)); }).catch(err => logger.error('SMS signup confirmation failed:', 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 // Notification: admin shift signup alert
try { try {
if (await isNotificationEnabled('notifyAdminShiftSignup')) { if (await isNotificationEnabled('notifyAdminShiftSignup')) {
@ -651,13 +634,17 @@ export const shiftsService = {
recordShiftSignup(); recordShiftSignup();
// Listmonk event sync // Publish shift.signup.created event (listeners: Listmonk, RC, CRM, n8n)
listmonkEventSyncService.onShiftSignup({ eventBus.publish('shift.signup.created', {
email: data.email, shiftId,
name: data.name,
shiftTitle: shift.title, shiftTitle: shift.title,
shiftDate: new Date(shift.date).toISOString().split('T')[0], shiftDate: new Date(shift.date).toISOString().split('T')[0],
}).catch(() => {}); userName: data.name || data.email,
userEmail: data.email,
userId: user?.id ?? null,
cutName: null,
signupType: 'public',
});
// Social group sync (fire-and-forget) // Social group sync (fire-and-forget)
groupService.syncShiftTeam(shiftId).catch(() => {}); groupService.syncShiftTeam(shiftId).catch(() => {});
@ -733,14 +720,16 @@ export const shiftsService = {
logger.error('Failed to enqueue cancellation notification:', err); logger.error('Failed to enqueue cancellation notification:', err);
} }
// Notify Rocket.Chat of cancellation // Publish shift.signup.cancelled event (listeners: RC, n8n)
if (shift) { if (shift) {
const shiftDateStr = new Date(shift.date).toLocaleDateString('en-CA', { month: 'short', day: 'numeric' }); eventBus.publish('shift.signup.cancelled', {
rocketchatWebhookService.onShiftCancellation({ shiftId,
userName: signup.userName || userEmail,
shiftTitle: shift.title, shiftTitle: shift.title,
shiftDate: shiftDateStr, shiftDate: new Date(shift.date).toISOString().split('T')[0],
}).catch(() => {}); userName: signup.userName || userEmail,
userEmail,
signupType: 'public',
});
} }
// Notification: admin shift cancellation alert // Notification: admin shift cancellation alert
@ -896,14 +885,6 @@ export const shiftsService = {
logger.error('Failed to send volunteer shift signup confirmation email:', err); 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 // Notification: admin shift signup alert
try { try {
if (await isNotificationEnabled('notifyAdminShiftSignup')) { if (await isNotificationEnabled('notifyAdminShiftSignup')) {
@ -980,13 +961,17 @@ export const shiftsService = {
logger.error('Failed to schedule shift thank-you:', err); logger.error('Failed to schedule shift thank-you:', err);
} }
// Listmonk event sync // Publish shift.signup.created event (listeners: Listmonk, RC, CRM, n8n)
listmonkEventSyncService.onShiftSignup({ eventBus.publish('shift.signup.created', {
email: user.email, shiftId,
name: user.name || user.email,
shiftTitle: shift.title, shiftTitle: shift.title,
shiftDate: new Date(shift.date).toISOString().split('T')[0], shiftDate: new Date(shift.date).toISOString().split('T')[0],
}).catch(() => {}); userName: user.name || user.email,
userEmail: user.email,
userId,
cutName: null,
signupType: 'volunteer',
});
// Social group sync (fire-and-forget) // Social group sync (fire-and-forget)
groupService.syncShiftTeam(shiftId).catch(() => {}); groupService.syncShiftTeam(shiftId).catch(() => {});
@ -1060,14 +1045,16 @@ export const shiftsService = {
logger.error('Failed to enqueue cancellation notification:', err); logger.error('Failed to enqueue cancellation notification:', err);
} }
// Notify Rocket.Chat of cancellation // Publish shift.signup.cancelled event (listeners: RC, n8n)
if (shift) { if (shift) {
const shiftDateStr = new Date(shift.date).toLocaleDateString('en-CA', { month: 'short', day: 'numeric' }); eventBus.publish('shift.signup.cancelled', {
rocketchatWebhookService.onShiftCancellation({ shiftId,
userName: user.name || user.email,
shiftTitle: shift.title, shiftTitle: shift.title,
shiftDate: shiftDateStr, shiftDate: new Date(shift.date).toISOString().split('T')[0],
}).catch(() => {}); userName: user.name || user.email,
userEmail: user.email,
signupType: 'volunteer',
});
} }
// Notification: admin shift cancellation alert // Notification: admin shift cancellation alert

View File

@ -1,6 +1,7 @@
import { FastifyInstance } from 'fastify'; import { FastifyInstance } from 'fastify';
import { createReadStream } from 'fs'; import { createReadStream } from 'fs';
import { access } from 'fs/promises'; import { access } from 'fs/promises';
import { resolve } from 'path';
import { prisma } from '../../../config/database'; import { prisma } from '../../../config/database';
import { optionalAuth } from '../middleware/auth'; import { optionalAuth } from '../middleware/auth';
import { logger } from '../../../utils/logger'; import { logger } from '../../../utils/logger';
@ -181,19 +182,25 @@ export async function photosPublicRoutes(fastify: FastifyInstance) {
} }
} }
if (!filePath || filePath.includes('..')) { if (!filePath) {
return reply.code(404).send({ message: 'Image variant not found' }); return reply.code(404).send({ message: 'Image variant not found' });
} }
const PHOTOS_BASE = '/media/local/photos';
const resolvedPath = resolve(filePath);
if (!resolvedPath.startsWith(resolve(PHOTOS_BASE) + '/')) {
logger.warn(`Photo path traversal attempt blocked: ${filePath}`);
return reply.code(403).send({ message: 'Access denied' });
}
try { try {
await access(filePath); await access(resolvedPath);
} catch { } catch {
return reply.code(404).send({ message: 'Image file not found' }); return reply.code(404).send({ message: 'Image file not found' });
} }
reply.header('Content-Type', contentType); reply.header('Content-Type', contentType);
reply.header('Cache-Control', 'public, max-age=604800, immutable'); reply.header('Cache-Control', 'public, max-age=604800, immutable');
return reply.send(createReadStream(filePath)); return reply.send(createReadStream(resolvedPath));
} }
); );
@ -208,19 +215,25 @@ export async function photosPublicRoutes(fastify: FastifyInstance) {
select: { thumbnailPath: true }, select: { thumbnailPath: true },
}); });
if (!photo?.thumbnailPath || photo.thumbnailPath.includes('..')) { if (!photo?.thumbnailPath) {
return reply.code(404).send({ message: 'Thumbnail not found' }); return reply.code(404).send({ message: 'Thumbnail not found' });
} }
const PHOTOS_BASE = '/media/local/photos';
const resolvedThumb = resolve(photo.thumbnailPath);
if (!resolvedThumb.startsWith(resolve(PHOTOS_BASE) + '/')) {
logger.warn(`Thumbnail path traversal attempt blocked: ${photo.thumbnailPath}`);
return reply.code(403).send({ message: 'Access denied' });
}
try { try {
await access(photo.thumbnailPath); await access(resolvedThumb);
} catch { } catch {
return reply.code(404).send({ message: 'Thumbnail file not found' }); return reply.code(404).send({ message: 'Thumbnail file not found' });
} }
reply.header('Content-Type', 'image/jpeg'); reply.header('Content-Type', 'image/jpeg');
reply.header('Cache-Control', 'public, max-age=604800, immutable'); reply.header('Cache-Control', 'public, max-age=604800, immutable');
return reply.send(createReadStream(photo.thumbnailPath)); return reply.send(createReadStream(resolvedThumb));
} }
); );

View File

@ -3,6 +3,7 @@ import { optionalAuth } from '../middleware/auth';
import { videoAnalyticsService } from '../services/video-analytics.service'; import { videoAnalyticsService } from '../services/video-analytics.service';
import { logger } from '../../../utils/logger'; import { logger } from '../../../utils/logger';
import { z } from 'zod'; import { z } from 'zod';
import { eventBus } from '../../../services/event-bus.service';
// Validation schemas // Validation schemas
const recordViewSchema = z.object({ const recordViewSchema = z.object({
@ -62,6 +63,13 @@ export async function videoTrackingRoutes(fastify: FastifyInstance) {
referer, referer,
}); });
eventBus.publish('media.video.viewed', {
videoId: String(videoId),
videoTitle: '', // Title not available in tracking context
userId: userId ?? null,
sessionId: String(viewId),
});
return { return {
success: true, success: true,
viewId, viewId,

View File

@ -7,6 +7,7 @@ import { join } from 'path';
import { extractVideoMetadata } from '../services/ffprobe.service'; import { extractVideoMetadata } from '../services/ffprobe.service';
import { ThumbnailService } from '../services/thumbnail.service'; import { ThumbnailService } from '../services/thumbnail.service';
import { logger } from '../../../utils/logger'; import { logger } from '../../../utils/logger';
import { eventBus } from '../../../services/event-bus.service';
// List videos endpoint (admin only for now) // List videos endpoint (admin only for now)
interface ListVideosQuery { interface ListVideosQuery {
@ -206,7 +207,15 @@ export async function videosRoutes(fastify: FastifyInstance) {
}, },
}); });
const userId = (request as any).user?.id || 'unknown';
logger.info(`Video ${videoId} published to ${category}`); logger.info(`Video ${videoId} published to ${category}`);
eventBus.publish('media.video.published', {
videoId: String(videoId),
title: video.title || video.filename || `Video #${videoId}`,
publishedByUserId: userId,
});
return { success: true, video }; return { success: true, video };
} catch (error: any) { } catch (error: any) {
logger.error(`Error publishing video ${videoId}:`, error); logger.error(`Error publishing video ${videoId}:`, error);
@ -233,6 +242,12 @@ export async function videosRoutes(fastify: FastifyInstance) {
}); });
logger.info(`Video ${videoId} unpublished`); logger.info(`Video ${videoId} unpublished`);
eventBus.publish('media.video.unpublished', {
videoId: String(videoId),
title: video.title || video.filename || `Video #${videoId}`,
});
return { success: true, video }; return { success: true, video };
} catch (error: any) { } catch (error: any) {
logger.error(`Error unpublishing video ${videoId}:`, error); logger.error(`Error unpublishing video ${videoId}:`, error);

View File

@ -8,7 +8,8 @@ import {
import { validate } from '../../middleware/validate'; import { validate } from '../../middleware/validate';
import { authenticate } from '../../middleware/auth.middleware'; import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware'; import { requireRole } from '../../middleware/rbac.middleware';
import { EVENTS_ROLES } from '../../utils/roles'; import { EVENTS_ROLES, hasAnyRole } from '../../utils/roles';
import { AppError } from '../../middleware/error-handler';
const router = Router(); const router = Router();
@ -44,6 +45,14 @@ router.get('/:id', authenticate, async (req: Request, res: Response, next: NextF
try { try {
const id = req.params.id as string; const id = req.params.id as string;
const item = await actionItemsService.findById(id); const item = await actionItemsService.findById(id);
const isAdmin = hasAnyRole(req.user!, EVENTS_ROLES);
const isAssignee = item.assigneeUserId === req.user!.id;
const isCreator = item.createdByUserId === req.user!.id;
if (!isAdmin && !isAssignee && !isCreator) {
throw new AppError(403, 'Insufficient permissions', 'FORBIDDEN');
}
res.json(item); res.json(item);
} catch (err) { next(err); } } catch (err) { next(err); }
}); });
@ -56,10 +65,19 @@ router.post('/', authenticate, requireRole(...EVENTS_ROLES), validate(createActi
} catch (err) { next(err); } } catch (err) { next(err); }
}); });
// Update action item (authenticate only - assignees can update their own) // Update action item — admins, assignees, or creators can update
router.put('/:id', authenticate, validate(updateActionItemSchema), async (req: Request, res: Response, next: NextFunction) => { router.put('/:id', authenticate, validate(updateActionItemSchema), async (req: Request, res: Response, next: NextFunction) => {
try { try {
const id = req.params.id as string; const id = req.params.id as string;
const existing = await actionItemsService.findById(id);
const isAdmin = hasAnyRole(req.user!, EVENTS_ROLES);
const isAssignee = existing.assigneeUserId === req.user!.id;
const isCreator = existing.createdByUserId === req.user!.id;
if (!isAdmin && !isAssignee && !isCreator) {
throw new AppError(403, 'Insufficient permissions', 'FORBIDDEN');
}
const item = await actionItemsService.update(id, req.body); const item = await actionItemsService.update(id, req.body);
res.json(item); res.json(item);
} catch (err) { next(err); } } catch (err) { next(err); }

View File

@ -221,4 +221,24 @@ router.get(
}, },
); );
// GET /api/observability/event-bus — EventBus stats
router.get(
'/event-bus',
async (_req: Request, res: Response) => {
const { eventBus } = await import('../../services/event-bus.service');
res.json(eventBus.getStats());
},
);
// GET /api/observability/engagement-leaderboard — Top engaged contacts
router.get(
'/engagement-leaderboard',
async (req: Request, res: Response) => {
const { engagementScoring } = await import('../../services/event-listeners/engagement-scoring.listener');
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
const leaderboard = await engagementScoring.getLeaderboard(limit);
res.json({ leaderboard, count: leaderboard.length });
},
);
export const observabilityRouter = router; export const observabilityRouter = router;

View File

@ -1,5 +1,7 @@
import { Router, Request, Response, NextFunction } from 'express'; import { Router, Request, Response, NextFunction } from 'express';
import { validate } from '../../middleware/validate'; import { validate } from '../../middleware/validate';
import { paymentCheckoutRateLimit } from '../../middleware/rate-limit';
import { requirePaymentsEnabled } from './payment-settings.service';
import { donationPagesService } from './donation-pages.service'; import { donationPagesService } from './donation-pages.service';
import { donationsService } from './donations.service'; import { donationsService } from './donations.service';
import { donationPageCheckoutSchema } from './donation-pages.schemas'; import { donationPageCheckoutSchema } from './donation-pages.schemas';
@ -29,6 +31,8 @@ router.get('/:slug', async (req: Request, res: Response, next: NextFunction) =>
// POST /api/donation-pages/:slug/donate — create Stripe checkout for this page // POST /api/donation-pages/:slug/donate — create Stripe checkout for this page
router.post( router.post(
'/:slug/donate', '/:slug/donate',
requirePaymentsEnabled,
paymentCheckoutRateLimit,
validate(donationPageCheckoutSchema), validate(donationPageCheckoutSchema),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {

View File

@ -31,7 +31,7 @@ export const listDonationPagesSchema = z.object({
export type ListDonationPagesInput = z.infer<typeof listDonationPagesSchema>; export type ListDonationPagesInput = z.infer<typeof listDonationPagesSchema>;
export const donationPageCheckoutSchema = z.object({ export const donationPageCheckoutSchema = z.object({
amountCents: z.number().int().min(100), amountCents: z.number().int().min(100).max(10000000), // max $100,000
email: z.string().email(), email: z.string().email(),
name: z.string().max(200).optional(), name: z.string().max(200).optional(),
message: z.string().max(2000).optional(), message: z.string().max(2000).optional(),

View File

@ -141,10 +141,18 @@ export const donationsService = {
}, },
}); });
const updated = await prisma.order.update({ // Stripe refund succeeded — update DB. If this fails, the charge.refunded
where: { id: orderId }, // webhook will reconcile the status as a fallback.
data: { status: 'REFUNDED' }, let updated;
}); try {
updated = await prisma.order.update({
where: { id: orderId },
data: { status: 'REFUNDED' },
});
} catch (dbErr) {
logger.error(`Stripe refund succeeded but DB update failed for order ${orderId}. Webhook will reconcile.`, dbErr);
throw new Error('Refund processed by Stripe but local status update failed. It will be reconciled shortly.');
}
logger.info(`Donation refunded: ${orderId}, $${(order.amountCAD / 100).toFixed(2)}`, { logger.info(`Donation refunded: ${orderId}, $${(order.amountCAD / 100).toFixed(2)}`, {
orderId, orderId,
@ -187,8 +195,6 @@ export const donationsService = {
'Donation Page': sanitizeCsvValue(o.donationPage?.title || 'General'), 'Donation Page': sanitizeCsvValue(o.donationPage?.title || 'General'),
'Message': sanitizeCsvValue(o.donorMessage || ''), 'Message': sanitizeCsvValue(o.donorMessage || ''),
'Anonymous': o.isAnonymous ? 'Yes' : 'No', 'Anonymous': o.isAnonymous ? 'Yes' : 'No',
'Stripe Payment Intent': o.stripePaymentIntentId || '',
'Stripe Checkout Session': o.stripeCheckoutSessionId || '',
'Completed At': o.completedAt ? o.completedAt.toISOString() : '', 'Completed At': o.completedAt ? o.completedAt.toISOString() : '',
'Order ID': o.id, 'Order ID': o.id,
})), { header: true }); })), { header: true });

View File

@ -1,3 +1,4 @@
import { Request, Response, NextFunction } from 'express';
import { prisma } from '../../config/database'; import { prisma } from '../../config/database';
import type { PaymentSettings } from '@prisma/client'; import type { PaymentSettings } from '@prisma/client';
import type { UpdatePaymentSettingsInput } from './payments.schemas'; import type { UpdatePaymentSettingsInput } from './payments.schemas';
@ -40,10 +41,16 @@ export const paymentSettingsService = {
async update(data: UpdatePaymentSettingsInput): Promise<PaymentSettings> { async update(data: UpdatePaymentSettingsInput): Promise<PaymentSettings> {
const toWrite = { ...data } as Record<string, unknown>; const toWrite = { ...data } as Record<string, unknown>;
// Encrypt sensitive fields // Encrypt sensitive fields, skipping masked sentinel values from the admin UI
for (const field of ENCRYPTED_FIELDS) { for (const field of ENCRYPTED_FIELDS) {
if (field in toWrite && typeof toWrite[field] === 'string' && toWrite[field]) { if (field in toWrite && typeof toWrite[field] === 'string') {
toWrite[field] = encrypt(toWrite[field] as string); const val = toWrite[field] as string;
if (!val || val.startsWith('••••')) {
// Empty or mask string submitted — preserve existing encrypted value
delete toWrite[field];
continue;
}
toWrite[field] = encrypt(val);
} }
} }
@ -69,3 +76,17 @@ export const paymentSettingsService = {
return decryptSettings(settings); return decryptSettings(settings);
}, },
}; };
/** Middleware: reject requests when payments are disabled in site settings */
export async function requirePaymentsEnabled(_req: Request, res: Response, next: NextFunction) {
try {
const settings = await prisma.siteSettings.findFirst({ select: { enablePayments: true } });
if (!settings?.enablePayments) {
res.status(403).json({ error: { message: 'Payments are not enabled', code: 'PAYMENTS_DISABLED' } });
return;
}
next();
} catch (err) {
next(err);
}
}

View File

@ -37,6 +37,7 @@ router.get('/settings', async (_req: Request, res: Response, next: NextFunction)
stripeSecretKey: settings.stripeSecretKey ? '••••' + settings.stripeSecretKey.slice(-4) : '', stripeSecretKey: settings.stripeSecretKey ? '••••' + settings.stripeSecretKey.slice(-4) : '',
stripeWebhookSecret: settings.stripeWebhookSecret ? '••••' + settings.stripeWebhookSecret.slice(-4) : '', stripeWebhookSecret: settings.stripeWebhookSecret ? '••••' + settings.stripeWebhookSecret.slice(-4) : '',
}; };
res.setHeader('Cache-Control', 'no-store');
res.json(masked); res.json(masked);
} catch (err) { } catch (err) {
next(err); next(err);
@ -50,7 +51,14 @@ router.put(
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
const settings = await paymentSettingsService.update(req.body); const settings = await paymentSettingsService.update(req.body);
res.json(settings); // Mask secrets in response (same as GET) to prevent leaking decrypted keys
const masked = {
...settings,
stripeSecretKey: settings.stripeSecretKey ? '••••' + settings.stripeSecretKey.slice(-4) : '',
stripeWebhookSecret: settings.stripeWebhookSecret ? '••••' + settings.stripeWebhookSecret.slice(-4) : '',
};
res.setHeader('Cache-Control', 'no-store');
res.json(masked);
} catch (err) { } catch (err) {
next(err); next(err);
} }

View File

@ -1,12 +1,13 @@
import { Router, Request, Response, NextFunction } from 'express'; import { Router, Request, Response, NextFunction } from 'express';
import { getPublishableKey } from '../../services/stripe.client'; import { getPublishableKey } from '../../services/stripe.client';
import { paymentSettingsService } from './payment-settings.service'; import { paymentSettingsService, requirePaymentsEnabled } from './payment-settings.service';
import { subscriptionsService } from './subscriptions.service'; import { subscriptionsService } from './subscriptions.service';
import { plansService } from './plans.service'; import { plansService } from './plans.service';
import { productsService } from './products.service'; import { productsService } from './products.service';
import { donationsService } from './donations.service'; import { donationsService } from './donations.service';
import { authenticate } from '../../middleware/auth.middleware'; import { authenticate } from '../../middleware/auth.middleware';
import { validate } from '../../middleware/validate'; import { validate } from '../../middleware/validate';
import { paymentCheckoutRateLimit } from '../../middleware/rate-limit';
import { import {
createSubscriptionCheckoutSchema, createSubscriptionCheckoutSchema,
createProductCheckoutSchema, createProductCheckoutSchema,
@ -85,6 +86,8 @@ router.get('/products/:slug', async (req: Request, res: Response, next: NextFunc
// POST /api/payments/subscribe — create subscription checkout (requires login) // POST /api/payments/subscribe — create subscription checkout (requires login)
router.post( router.post(
'/subscribe', '/subscribe',
requirePaymentsEnabled,
paymentCheckoutRateLimit,
authenticate, authenticate,
validate(createSubscriptionCheckoutSchema), validate(createSubscriptionCheckoutSchema),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
@ -105,6 +108,8 @@ router.post(
// POST /api/payments/purchase — create product checkout (guest or logged-in) // POST /api/payments/purchase — create product checkout (guest or logged-in)
router.post( router.post(
'/purchase', '/purchase',
requirePaymentsEnabled,
paymentCheckoutRateLimit,
validate(createProductCheckoutSchema), validate(createProductCheckoutSchema),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
@ -122,6 +127,8 @@ router.post(
// POST /api/payments/donate — create donation checkout (no auth required) // POST /api/payments/donate — create donation checkout (no auth required)
router.post( router.post(
'/donate', '/donate',
requirePaymentsEnabled,
paymentCheckoutRateLimit,
validate(createDonationCheckoutSchema), validate(createDonationCheckoutSchema),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {

View File

@ -84,7 +84,7 @@ export const createProductCheckoutSchema = z.object({
// --- Donation --- // --- Donation ---
export const createDonationCheckoutSchema = z.object({ export const createDonationCheckoutSchema = z.object({
amountCents: z.number().int().min(100), amountCents: z.number().int().min(100).max(10000000), // max $100,000
email: z.string().email(), email: z.string().email(),
name: z.string().max(200).optional(), name: z.string().max(200).optional(),
message: z.string().max(2000).optional(), message: z.string().max(2000).optional(),
@ -111,7 +111,7 @@ export const subscriptionFiltersSchema = z.object({
export const orderFiltersSchema = z.object({ export const orderFiltersSchema = z.object({
page: z.coerce.number().int().min(1).default(1), page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20), limit: z.coerce.number().int().min(1).max(100).default(20),
status: z.enum(['PENDING', 'COMPLETED', 'FAILED', 'REFUNDED']).optional(), status: z.enum(['PENDING', 'COMPLETED', 'FAILED', 'REFUNDED', 'DISPUTED']).optional(),
type: z.enum(['product', 'donation']).optional(), type: z.enum(['product', 'donation']).optional(),
search: z.string().optional(), search: z.string().optional(),
}); });

View File

@ -228,12 +228,16 @@ export const productsService = {
/** Create Stripe Checkout for a product purchase */ /** Create Stripe Checkout for a product purchase */
async createProductCheckout(productId: string, buyerEmail: string, buyerName?: string, userId?: string) { async createProductCheckout(productId: string, buyerEmail: string, buyerName?: string, userId?: string) {
const stripe = await getStripe(); const stripe = await getStripe();
const product = await prisma.product.findUnique({ where: { id: productId } });
if (!product || !product.isActive) throw new Error('Product not found or inactive');
if (product.maxPurchases && product.purchaseCount >= product.maxPurchases) { // Atomic availability check to prevent overselling under concurrency
throw new Error('Product is sold out'); const product = await prisma.$transaction(async (tx) => {
} const p = await tx.product.findUnique({ where: { id: productId } });
if (!p || !p.isActive) throw new Error('Product not found or inactive');
if (p.maxPurchases && p.purchaseCount >= p.maxPurchases) {
throw new Error('Product is sold out');
}
return p;
});
const session = await stripe.checkout.sessions.create({ const session = await stripe.checkout.sessions.create({
mode: 'payment', mode: 'payment',
@ -367,9 +371,16 @@ export const productsService = {
await stripe.refunds.create({ payment_intent: order.stripePaymentIntentId }); await stripe.refunds.create({ payment_intent: order.stripePaymentIntentId });
} }
return prisma.order.update({ // Stripe refund succeeded — update DB. If this fails, the charge.refunded
where: { id: orderId }, // webhook will reconcile the status as a fallback.
data: { status: 'REFUNDED' }, try {
}); return await prisma.order.update({
where: { id: orderId },
data: { status: 'REFUNDED' },
});
} catch (dbErr) {
logger.error(`Stripe refund succeeded but DB update failed for order ${orderId}. Webhook will reconcile.`, dbErr);
throw new Error('Refund processed by Stripe but local status update failed. It will be reconciled shortly.');
}
}, },
}; };

View File

@ -259,8 +259,6 @@ export const subscriptionsService = {
'Current Period End': s.currentPeriodEnd ? s.currentPeriodEnd.toISOString() : '', 'Current Period End': s.currentPeriodEnd ? s.currentPeriodEnd.toISOString() : '',
'Cancel at Period End': s.cancelAtPeriodEnd ? 'Yes' : 'No', 'Cancel at Period End': s.cancelAtPeriodEnd ? 'Yes' : 'No',
'Cancelled At': s.cancelledAt ? s.cancelledAt.toISOString() : '', 'Cancelled At': s.cancelledAt ? s.cancelledAt.toISOString() : '',
'Stripe Subscription ID': s.stripeSubscriptionId || '',
'Stripe Customer ID': s.stripeCustomerId || '',
'Subscription ID': s.id.toString(), 'Subscription ID': s.id.toString(),
'User ID': s.userId, 'User ID': s.userId,
})), { header: true }); })), { header: true });

View File

@ -4,7 +4,7 @@ import { getStripe, getWebhookSecret } from '../../services/stripe.client';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
import { recordCrmActivity } from '../../utils/crm-activity'; import { recordCrmActivity } from '../../utils/crm-activity';
import { paymentEmailService } from './payment-email.service'; import { paymentEmailService } from './payment-email.service';
import { listmonkEventSyncService } from '../../services/listmonk-event-sync.service'; import { eventBus } from '../../services/event-bus.service';
// Helper to extract subscription ID from invoice (may be string, object, or missing in newer types) // Helper to extract subscription ID from invoice (may be string, object, or missing in newer types)
function getSubscriptionId(invoice: Stripe.Invoice): string | null { function getSubscriptionId(invoice: Stripe.Invoice): string | null {
@ -48,6 +48,12 @@ export const webhookService = {
case 'charge.refunded': case 'charge.refunded':
await this.handleChargeRefunded(event.data.object as Stripe.Charge); await this.handleChargeRefunded(event.data.object as Stripe.Charge);
break; break;
case 'charge.dispute.created':
await this.handleDisputeCreated(event.data.object as Stripe.Dispute);
break;
case 'charge.dispute.closed':
await this.handleDisputeClosed(event.data.object as Stripe.Dispute);
break;
case 'checkout.session.expired': case 'checkout.session.expired':
await this.handleCheckoutExpired(event.data.object as Stripe.Checkout.Session); await this.handleCheckoutExpired(event.data.object as Stripe.Checkout.Session);
break; break;
@ -142,12 +148,12 @@ export const webhookService = {
const subUser = await prisma.user.findUnique({ where: { id: userId }, select: { email: true, name: true } }); const subUser = await prisma.user.findUnique({ where: { id: userId }, select: { email: true, name: true } });
const plan = await prisma.subscriptionPlan.findUnique({ where: { id: parseInt(planId, 10) }, select: { name: true } }); const plan = await prisma.subscriptionPlan.findUnique({ where: { id: parseInt(planId, 10) }, select: { name: true } });
if (subUser) { if (subUser) {
listmonkEventSyncService.onSubscriptionActivated({ eventBus.publish('payment.subscription.activated', {
email: subUser.email, email: subUser.email,
name: subUser.name || '', name: subUser.name || '',
planName: plan?.name || `Plan ${planId}`, planName: plan?.name || `Plan ${planId}`,
subscriptionId, subscriptionId,
}).catch(() => {}); });
} }
}, },
@ -207,13 +213,13 @@ export const webhookService = {
// Sync to Listmonk Donors list (fire-and-forget) // Sync to Listmonk Donors list (fire-and-forget)
if (updatedOrder.buyerEmail) { if (updatedOrder.buyerEmail) {
listmonkEventSyncService.onProductPurchased({ eventBus.publish('payment.product.purchased', {
email: updatedOrder.buyerEmail, email: updatedOrder.buyerEmail,
name: updatedOrder.buyerName || '', name: updatedOrder.buyerName || '',
productTitle: updatedOrder.product?.title || 'Product', productTitle: updatedOrder.product?.title || 'Product',
amountCents: updatedOrder.amountCAD, amountCents: updatedOrder.amountCAD,
orderId: updatedOrder.id, orderId: updatedOrder.id,
}).catch(() => {}); });
} }
// CRM activity (fire-and-forget) // CRM activity (fire-and-forget)
@ -282,12 +288,12 @@ export const webhookService = {
// Sync to Listmonk Donors list (fire-and-forget) // Sync to Listmonk Donors list (fire-and-forget)
if (order.buyerEmail) { if (order.buyerEmail) {
listmonkEventSyncService.onDonationCompleted({ eventBus.publish('payment.donation.completed', {
email: order.buyerEmail, email: order.buyerEmail,
name: order.buyerName || '', name: order.buyerName || '',
amountCents: order.amountCAD, amountCents: order.amountCAD,
orderId: order.id, orderId: order.id,
}).catch(() => {}); });
} }
// CRM activity (fire-and-forget) // CRM activity (fire-and-forget)
@ -518,17 +524,103 @@ export const webhookService = {
} }
} }
} }
},
// Check payments async handleDisputeCreated(dispute: Stripe.Dispute) {
const payment = await prisma.payment.findFirst({ const paymentIntentId = typeof dispute.payment_intent === 'string'
? dispute.payment_intent
: (dispute.payment_intent as { id: string } | null)?.id;
if (!paymentIntentId) return;
const order = await prisma.order.findFirst({
where: { stripePaymentIntentId: paymentIntentId }, where: { stripePaymentIntentId: paymentIntentId },
}); });
if (payment && payment.status !== 'refunded') { if (!order || order.status === 'DISPUTED') return;
await prisma.payment.update({
where: { id: payment.id }, const previousStatus = order.status;
data: { status: 'refunded' }, await prisma.order.update({
where: { id: order.id },
data: { status: 'DISPUTED' },
});
// Invalidate event tickets if applicable
if (order.type === 'event_ticket') {
const tickets = await prisma.ticket.findMany({
where: { orderId: order.id, status: 'VALID' },
}); });
for (const ticket of tickets) {
await prisma.ticket.update({
where: { id: ticket.id },
data: { status: 'CANCELLED' },
});
}
if (tickets.length > 0) {
logger.info(`Invalidated ${tickets.length} tickets for disputed order ${order.id}`);
}
} }
await this.createAuditLog('dispute_created', {
orderId: order.id,
previousStatus,
disputeId: dispute.id,
reason: dispute.reason,
amount: dispute.amount,
});
logger.warn(`Dispute created for order ${order.id}: ${dispute.reason} ($${(dispute.amount / 100).toFixed(2)})`);
},
async handleDisputeClosed(dispute: Stripe.Dispute) {
const paymentIntentId = typeof dispute.payment_intent === 'string'
? dispute.payment_intent
: (dispute.payment_intent as { id: string } | null)?.id;
if (!paymentIntentId) return;
const order = await prisma.order.findFirst({
where: { stripePaymentIntentId: paymentIntentId },
});
if (!order) return;
// Stripe types don't include closed dispute statuses (won/lost/charge_refunded)
const disputeStatus = dispute.status as string;
// If dispute was won (resolved in our favor), restore the order + tickets
if (disputeStatus === 'won') {
await prisma.order.update({
where: { id: order.id },
data: { status: 'COMPLETED' },
});
// Restore tickets that were cancelled when the dispute was opened
if (order.type === 'event_ticket') {
await prisma.ticket.updateMany({
where: { orderId: order.id, status: 'CANCELLED' },
data: { status: 'VALID' },
});
logger.info(`Restored tickets for dispute-won order ${order.id}`);
}
logger.info(`Dispute won for order ${order.id}, restored to COMPLETED`);
} else if (disputeStatus === 'lost') {
// Dispute lost — funds returned to customer
await prisma.order.update({
where: { id: order.id },
data: { status: 'REFUNDED' },
});
logger.warn(`Dispute lost for order ${order.id}, marked REFUNDED`);
} else if (disputeStatus === 'charge_refunded') {
// Merchant refunded while dispute was in flight — dispute auto-closed
await prisma.order.update({
where: { id: order.id },
data: { status: 'REFUNDED' },
});
logger.info(`Dispute closed via refund for order ${order.id}`);
}
await this.createAuditLog('dispute_closed', {
orderId: order.id,
disputeId: dispute.id,
outcome: dispute.status,
});
}, },
async handleCheckoutExpired(session: Stripe.Checkout.Session) { async handleCheckoutExpired(session: Stripe.Checkout.Session) {
@ -562,8 +654,19 @@ export const webhookService = {
async createAuditLog(action: string, metadata: Record<string, unknown>) { async createAuditLog(action: string, metadata: Record<string, unknown>) {
try { try {
const orderId = typeof metadata.orderId === 'string' ? metadata.orderId : undefined;
const userId = typeof metadata.userId === 'string' ? metadata.userId : undefined;
await prisma.paymentAuditLog.create({
data: {
action,
orderId: orderId || null,
userId: userId || null,
metadata: metadata as import('@prisma/client').Prisma.InputJsonValue,
},
});
logger.info(`Payment audit: ${action}`, metadata); logger.info(`Payment audit: ${action}`, metadata);
} catch (err) { } catch (err) {
// Audit log failure must not break payment processing
logger.error('Failed to create audit log', err); logger.error('Failed to create audit log', err);
} }
}, },

View File

@ -2,6 +2,7 @@ import { prisma } from '../../config/database';
import { redis } from '../../config/redis'; import { redis } from '../../config/redis';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
import { AppError } from '../../middleware/error-handler'; import { AppError } from '../../middleware/error-handler';
import { eventBus } from '../../services/event-bus.service';
import type { Prisma } from '@prisma/client'; import type { Prisma } from '@prisma/client';
import type { import type {
ListPeopleInput, ListPeopleInput,
@ -1053,13 +1054,12 @@ export const peopleService = {
}); });
if (input.email) { if (input.email) {
import('../../services/listmonk-event-sync.service').then(({ listmonkEventSyncService }) => { eventBus.publish('contact.tags.changed', {
listmonkEventSyncService.onContactTagsChanged({ email: input.email!,
email: input.email!, name: contact.displayName || '',
name: contact.displayName || '', contactId: contact.id,
addedTags: initialTags, addedTags: initialTags,
removedTags: [], removedTags: [],
}).catch(err => logger.debug('Listmonk tag sync failed on create:', err));
}); });
} }
} }
@ -1133,13 +1133,12 @@ export const peopleService = {
const email = (data.email !== undefined ? (data.email === '' ? null : data.email) : existing.email); const email = (data.email !== undefined ? (data.email === '' ? null : data.email) : existing.email);
if (email) { if (email) {
import('../../services/listmonk-event-sync.service').then(({ listmonkEventSyncService }) => { eventBus.publish('contact.tags.changed', {
listmonkEventSyncService.onContactTagsChanged({ email,
email, name: contact.displayName || '',
name: contact.displayName || '', contactId: existing.id,
addedTags, addedTags,
removedTags, removedTags,
}).catch(err => logger.debug('Listmonk tag sync failed:', err));
}); });
} }
} }
@ -1358,13 +1357,12 @@ export const peopleService = {
const mergedEmail = target.email || sourceContact?.email; const mergedEmail = target.email || sourceContact?.email;
if (mergedEmail) { if (mergedEmail) {
import('../../services/listmonk-event-sync.service').then(({ listmonkEventSyncService }) => { eventBus.publish('contact.tags.changed', {
listmonkEventSyncService.onContactTagsChanged({ email: mergedEmail,
email: mergedEmail, name: target.displayName,
name: target.displayName, contactId: targetId,
addedTags: addedToTarget, addedTags: addedToTarget,
removedTags: [], removedTags: [],
}).catch(err => logger.debug('Listmonk tag sync failed on merge:', err));
}); });
} }
} }

View File

@ -0,0 +1,123 @@
import { Router, Request, Response, NextFunction } from 'express';
import { strawPollsService } from './polls.service';
import {
listStrawPollsSchema,
submitStrawPollVoteSchema,
submitStrawPollCommentSchema,
challengeVoteSchema,
} from './polls.schemas';
import { validate } from '../../middleware/validate';
import { authenticate, optionalAuth } from '../../middleware/auth.middleware';
import { strawPollVoteRateLimit, strawPollCommentRateLimit } from './polls.rate-limits';
import { pollSseService } from './polls-sse.service';
const publicRouter = Router();
// List active public polls
publicRouter.get('/public', validate(listStrawPollsSchema, 'query'), async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await strawPollsService.findAllPublic(req.query as any);
res.json(result);
} catch (err) { next(err); }
});
// Get poll by slug (public)
publicRouter.get('/public/:slug', optionalAuth, async (req: Request, res: Response, next: NextFunction) => {
try {
const slug = req.params.slug as string;
const voterToken = req.query.voterToken as string | undefined;
const clientIp = req.ip || req.socket.remoteAddress || '';
const poll = await strawPollsService.findBySlugPublic(slug, req.user?.id, voterToken, clientIp);
if (!poll) return res.status(404).json({ error: 'Poll not found' });
// For AFTER_VOTE visibility, strip results if not voted
if ('resultVisibility' in poll && poll.resultVisibility === 'AFTER_VOTE' && !poll.hasVoted) {
const stripped = {
...poll,
options: (poll as any).options?.map((o: any) => ({ ...o, voteCount: undefined })),
totalVotes: undefined,
showResults: false,
};
return res.json(stripped);
}
res.json(poll);
} catch (err) { next(err); }
});
// Submit vote
publicRouter.post('/public/:slug/vote', optionalAuth, strawPollVoteRateLimit, validate(submitStrawPollVoteSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const slug = req.params.slug as string;
const clientIp = req.ip || req.socket.remoteAddress || '';
const result = await strawPollsService.submitVote(slug, req.body, req.user?.id, clientIp);
res.json(result);
} catch (err) {
if (err instanceof Error && (err.message.includes('not found') || err.message.includes('not active') || err.message.includes('Invalid option'))) {
return res.status(400).json({ error: err.message });
}
if (err instanceof Error && err.message.includes('required')) {
return res.status(401).json({ error: err.message });
}
next(err);
}
});
// Submit comment
publicRouter.post('/public/:slug/comment', optionalAuth, strawPollCommentRateLimit, validate(submitStrawPollCommentSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const slug = req.params.slug as string;
const comment = await strawPollsService.addComment(slug, req.body, req.user?.id);
res.status(201).json(comment);
} catch (err) {
if (err instanceof Error && err.message.includes('disabled')) {
return res.status(400).json({ error: err.message });
}
next(err);
}
});
// SSE stream for live results
publicRouter.get('/public/:slug/live', (req: Request, res: Response) => {
const slug = req.params.slug as string;
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no', // Disable nginx buffering
});
res.write(': connected\n\n');
const connectionId = pollSseService.addClient(slug, res);
if (!connectionId) {
res.write('event: error\ndata: {"message":"Too many connections"}\n\n');
res.end();
return;
}
req.on('close', () => {
pollSseService.removeClient(connectionId);
});
});
// Challenge a friend (requires auth)
publicRouter.post('/public/:slug/challenge', authenticate, validate(challengeVoteSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const slug = req.params.slug as string;
// Look up poll ID from slug
const { prisma } = await import('../../config/database');
const poll = await prisma.strawPoll.findUnique({ where: { slug }, select: { id: true } });
if (!poll) return res.status(404).json({ error: 'Poll not found' });
const challenge = await strawPollsService.challengeFriend(poll.id, req.user!.id, req.body.challengedUserId);
res.status(201).json(challenge);
} catch (err) {
if (err instanceof Error && err.message.includes('not found')) {
return res.status(404).json({ error: err.message });
}
next(err);
}
});
export { publicRouter as strawPollPublicRouter };

View File

@ -0,0 +1,112 @@
import type { Response } from 'express';
import { logger } from '../../utils/logger';
interface PollSSEClient {
id: string;
res: Response;
connectedAt: Date;
}
/** Poll-level SSE manager keyed by slug (supports anonymous viewers) */
class PollSSEService {
private clients = new Map<string, PollSSEClient[]>(); // pollSlug -> connections
private heartbeatInterval: NodeJS.Timeout | null = null;
private static MAX_CONNECTIONS_PER_POLL = 200;
startHeartbeat() {
if (this.heartbeatInterval) return;
this.heartbeatInterval = setInterval(() => {
let total = 0;
for (const [, clients] of this.clients) {
for (const client of clients) {
try {
client.res.write(': heartbeat\n\n');
total++;
} catch {
this.removeClient(client.id);
}
}
}
if (total > 0) {
logger.debug(`Poll SSE heartbeat sent to ${total} clients`);
}
}, 30_000);
}
stopHeartbeat() {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
}
addClient(slug: string, res: Response): string | null {
const existing = this.clients.get(slug) ?? [];
if (existing.length >= PollSSEService.MAX_CONNECTIONS_PER_POLL) {
return null; // Reject — too many connections for this poll
}
const id = `${slug}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const client: PollSSEClient = { id, res, connectedAt: new Date() };
if (!this.clients.has(slug)) {
this.clients.set(slug, []);
}
this.clients.get(slug)!.push(client);
logger.debug(`Poll SSE client connected: ${id} (poll: ${slug})`);
return id;
}
removeClient(connectionId: string) {
for (const [slug, clients] of this.clients) {
const idx = clients.findIndex((c) => c.id === connectionId);
if (idx >= 0) {
clients.splice(idx, 1);
if (clients.length === 0) {
this.clients.delete(slug);
}
logger.debug(`Poll SSE client disconnected: ${connectionId} (poll: ${slug})`);
return;
}
}
}
/** Broadcast event to all viewers of a poll */
broadcast(slug: string, event: string, data: unknown) {
const clients = this.clients.get(slug);
if (!clients || clients.length === 0) return;
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
for (const client of clients) {
try {
client.res.write(payload);
} catch {
this.removeClient(client.id);
}
}
}
getConnectionCount(slug?: string): number {
if (slug) return this.clients.get(slug)?.length ?? 0;
let count = 0;
for (const clients of this.clients.values()) {
count += clients.length;
}
return count;
}
closeAll() {
this.stopHeartbeat();
for (const [, clients] of this.clients) {
for (const client of clients) {
try { client.res.end(); } catch {}
}
}
this.clients.clear();
logger.info('Poll SSE: All connections closed');
}
}
export const pollSseService = new PollSSEService();

View File

@ -0,0 +1,30 @@
import { Router, Request, Response, NextFunction } from 'express';
import { strawPollsService } from './polls.service';
import { redis } from '../../config/redis';
const widgetRouter = Router();
// Lightweight JSON for MkDocs widget embeds (cached 60s)
widgetRouter.get('/widget/:slug', async (req: Request, res: Response, next: NextFunction) => {
try {
const slug = req.params.slug as string;
const cacheKey = `straw-poll-widget:${slug}`;
// Check Redis cache
const cached = await redis.get(cacheKey);
if (cached) {
res.set('X-Cache', 'HIT');
return res.json(JSON.parse(cached));
}
const poll = await strawPollsService.findBySlugWidget(slug);
if (!poll) return res.status(404).json({ error: 'Poll not found' });
// Cache for 60 seconds
await redis.set(cacheKey, JSON.stringify(poll), 'EX', 60);
res.set('X-Cache', 'MISS');
res.json(poll);
} catch (err) { next(err); }
});
export { widgetRouter as strawPollWidgetRouter };

View File

@ -0,0 +1,37 @@
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import { redis } from '../../config/redis';
export const strawPollVoteRateLimit = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 30,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
prefix: 'rl:straw-poll-vote:',
}),
message: {
error: {
message: 'Too many vote submissions, please try again later',
code: 'STRAW_POLL_VOTE_RATE_LIMIT_EXCEEDED',
},
},
});
export const strawPollCommentRateLimit = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 60,
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
prefix: 'rl:straw-poll-comment:',
}),
message: {
error: {
message: 'Too many comments, please try again later',
code: 'STRAW_POLL_COMMENT_RATE_LIMIT_EXCEEDED',
},
},
});

View File

@ -0,0 +1,121 @@
import { Router, Request, Response, NextFunction } from 'express';
import { strawPollsService } from './polls.service';
import {
createStrawPollSchema,
updateStrawPollSchema,
listStrawPollsSchema,
generateLinksSchema,
} from './polls.schemas';
import { validate } from '../../middleware/validate';
import { authenticate } from '../../middleware/auth.middleware';
import { requireRole } from '../../middleware/rbac.middleware';
import { POLLS_ROLES } from '../../utils/roles';
const adminRouter = Router();
adminRouter.use(authenticate);
adminRouter.use(requireRole(...POLLS_ROLES));
// List polls
adminRouter.get('/', validate(listStrawPollsSchema, 'query'), async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await strawPollsService.findAll(req.query as any);
res.json(result);
} catch (err) { next(err); }
});
// Get poll detail
adminRouter.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const poll = await strawPollsService.findById(id);
if (!poll) return res.status(404).json({ error: 'Poll not found' });
res.json(poll);
} catch (err) { next(err); }
});
// Create poll
adminRouter.post('/', validate(createStrawPollSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const poll = await strawPollsService.create(req.body, req.user!.id);
res.status(201).json(poll);
} catch (err) { next(err); }
});
// Update poll
adminRouter.put('/:id', validate(updateStrawPollSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const poll = await strawPollsService.update(id, req.body);
if (!poll) return res.status(404).json({ error: 'Poll not found' });
res.json(poll);
} catch (err) { next(err); }
});
// Delete poll
adminRouter.delete('/:id', async (req: Request, res: Response, next: NextFunction) => {
try {
const id = req.params.id as string;
const poll = await strawPollsService.delete(id);
if (!poll) return res.status(404).json({ error: 'Poll not found' });
res.json({ success: true });
} catch (err) { next(err); }
});
// Activate (DRAFT -> ACTIVE)
adminRouter.post('/:id/activate', async (req: Request, res: Response, next: NextFunction) => {
try {
const poll = await strawPollsService.activate(req.params.id as string);
res.json(poll);
} catch (err) { next(err); }
});
// Close (ACTIVE -> CLOSED)
adminRouter.post('/:id/close', async (req: Request, res: Response, next: NextFunction) => {
try {
const poll = await strawPollsService.closePoll(req.params.id as string);
if (!poll) return res.status(400).json({ error: 'Poll cannot be closed (not active)' });
res.json(poll);
} catch (err) { next(err); }
});
// Reopen (CLOSED -> ACTIVE)
adminRouter.post('/:id/reopen', async (req: Request, res: Response, next: NextFunction) => {
try {
const poll = await strawPollsService.reopenPoll(req.params.id as string);
res.json(poll);
} catch (err) { next(err); }
});
// Archive (CLOSED -> ARCHIVED)
adminRouter.post('/:id/archive', async (req: Request, res: Response, next: NextFunction) => {
try {
const poll = await strawPollsService.archivePoll(req.params.id as string);
res.json(poll);
} catch (err) { next(err); }
});
// Delete a vote (moderation)
adminRouter.delete('/:id/votes/:voteId', async (req: Request, res: Response, next: NextFunction) => {
try {
await strawPollsService.deleteVote(req.params.id as string, req.params.voteId as string);
res.json({ success: true });
} catch (err) { next(err); }
});
// Delete a comment (moderation)
adminRouter.delete('/:id/comments/:commentId', async (req: Request, res: Response, next: NextFunction) => {
try {
await strawPollsService.deleteComment(req.params.id as string, req.params.commentId as string);
res.json({ success: true });
} catch (err) { next(err); }
});
// Generate voting links (TOKEN_GATED)
adminRouter.post('/:id/generate-links', validate(generateLinksSchema), async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await strawPollsService.generateVotingTokens(req.params.id as string, req.body.count);
res.json(result);
} catch (err) { next(err); }
});
export { adminRouter as strawPollAdminRouter };

View File

@ -0,0 +1,67 @@
import { z } from 'zod';
import { StrawPollType, StrawPollStatus, StrawPollIdentityMode, StrawPollResultVisibility } from '@prisma/client';
export const createStrawPollSchema = z.object({
title: z.string().min(1, 'Title is required').max(200),
description: z.string().max(2000).optional(),
type: z.nativeEnum(StrawPollType),
identityMode: z.nativeEnum(StrawPollIdentityMode).optional().default('ANONYMOUS'),
resultVisibility: z.nativeEnum(StrawPollResultVisibility).optional().default('LIVE'),
allowComments: z.boolean().optional().default(true),
closesAt: z.string().datetime().optional(),
closeThreshold: z.number().int().min(1).max(100000).nullable().optional(),
isPrivate: z.boolean().optional().default(false),
options: z.array(z.object({
label: z.string().min(1, 'Option label is required').max(500),
})).min(2, 'At least 2 options required').max(20, 'Maximum 20 options').optional(),
});
export const updateStrawPollSchema = z.object({
title: z.string().min(1).max(200).optional(),
description: z.string().max(2000).nullable().optional(),
identityMode: z.nativeEnum(StrawPollIdentityMode).optional(),
resultVisibility: z.nativeEnum(StrawPollResultVisibility).optional(),
allowComments: z.boolean().optional(),
closesAt: z.string().datetime().nullable().optional(),
closeThreshold: z.number().int().min(1).max(100000).nullable().optional(),
isPrivate: z.boolean().optional(),
options: z.array(z.object({
id: z.string().optional(),
label: z.string().min(1).max(500),
})).min(2).max(20).optional(),
});
export const submitStrawPollVoteSchema = z.object({
optionId: z.string().min(1, 'Option is required'),
voterName: z.string().min(1).max(100).optional(),
voterToken: z.string().optional(),
});
export const submitStrawPollCommentSchema = z.object({
authorName: z.string().min(1, 'Name is required').max(100),
content: z.string().min(1, 'Comment is required').max(2000),
});
export const listStrawPollsSchema = z.object({
page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().positive().max(100).default(20),
search: z.string().optional(),
status: z.nativeEnum(StrawPollStatus).optional(),
type: z.nativeEnum(StrawPollType).optional(),
});
export const challengeVoteSchema = z.object({
challengedUserId: z.string().min(1, 'User ID is required'),
});
export const generateLinksSchema = z.object({
count: z.number().int().min(1).max(500).default(10),
});
export type CreateStrawPollInput = z.infer<typeof createStrawPollSchema>;
export type UpdateStrawPollInput = z.infer<typeof updateStrawPollSchema>;
export type SubmitStrawPollVoteInput = z.infer<typeof submitStrawPollVoteSchema>;
export type SubmitStrawPollCommentInput = z.infer<typeof submitStrawPollCommentSchema>;
export type ListStrawPollsInput = z.infer<typeof listStrawPollsSchema>;
export type ChallengeVoteInput = z.infer<typeof challengeVoteSchema>;
export type GenerateLinksInput = z.infer<typeof generateLinksSchema>;

View File

@ -0,0 +1,656 @@
import crypto from 'crypto';
import { Prisma, StrawPollStatus, StrawPollType } from '@prisma/client';
import { prisma } from '../../config/database';
import { logger } from '../../utils/logger';
import { generateSlug } from '../../utils/slug';
import { pollSseService } from './polls-sse.service';
import type {
CreateStrawPollInput,
UpdateStrawPollInput,
SubmitStrawPollVoteInput,
SubmitStrawPollCommentInput,
ListStrawPollsInput,
} from './polls.schemas';
function generateVoterToken(): string {
return crypto.randomBytes(18).toString('base64url').slice(0, 24);
}
// Select configs for different contexts
const pollListSelect = {
id: true,
slug: true,
title: true,
type: true,
status: true,
identityMode: true,
resultVisibility: true,
isPrivate: true,
closesAt: true,
closeThreshold: true,
allowComments: true,
createdByUserId: true,
createdAt: true,
updatedAt: true,
_count: { select: { votes: true, comments: true, options: true } },
} satisfies Prisma.StrawPollSelect;
const pollDetailSelect = {
...pollListSelect,
description: true,
autoCloseJobId: true,
options: { orderBy: { sortOrder: 'asc' as const }, include: { _count: { select: { votes: true } } } },
votes: {
select: {
id: true, optionId: true, userId: true, voterName: true, createdAt: true,
user: { select: { id: true, name: true, email: true } },
},
orderBy: { createdAt: 'desc' as const },
},
comments: {
select: { id: true, authorName: true, content: true, userId: true, createdAt: true },
orderBy: { createdAt: 'desc' as const },
},
createdBy: { select: { id: true, name: true, email: true } },
};
const publicPollSelect = {
id: true,
slug: true,
title: true,
description: true,
type: true,
status: true,
identityMode: true,
resultVisibility: true,
allowComments: true,
isPrivate: true,
closesAt: true,
createdByUserId: true,
createdAt: true,
options: {
select: { id: true, label: true, sortOrder: true, _count: { select: { votes: true } } },
orderBy: { sortOrder: 'asc' as const },
},
_count: { select: { votes: true, comments: true } },
comments: {
select: { id: true, authorName: true, content: true, createdAt: true },
orderBy: { createdAt: 'desc' as const },
},
createdBy: { select: { name: true } },
};
class StrawPollsService {
// ===== Admin CRUD =====
async findAll(filters: ListStrawPollsInput) {
const { page, limit, search, status, type } = filters;
const where: Prisma.StrawPollWhereInput = {};
if (status) where.status = status;
if (type) where.type = type;
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
];
}
const [polls, total] = await Promise.all([
prisma.strawPoll.findMany({
where,
select: pollListSelect,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
prisma.strawPoll.count({ where }),
]);
return { polls, total, page, limit };
}
async findById(id: string) {
return prisma.strawPoll.findUnique({ where: { id }, select: pollDetailSelect });
}
async create(data: CreateStrawPollInput, userId: string) {
const slug = generateSlug(data.title);
// For YES_NO_ABSTAIN, auto-create the three fixed options
const options = data.type === StrawPollType.YES_NO_ABSTAIN
? [
{ label: 'Yes', sortOrder: 0 },
{ label: 'No', sortOrder: 1 },
{ label: 'Abstain', sortOrder: 2 },
]
: (data.options ?? []).map((opt, i) => ({ label: opt.label, sortOrder: i }));
if (data.type === StrawPollType.SINGLE_CHOICE && options.length < 2) {
throw new Error('SINGLE_CHOICE polls require at least 2 options');
}
const poll = await prisma.strawPoll.create({
data: {
slug,
title: data.title,
description: data.description,
type: data.type,
identityMode: data.identityMode,
resultVisibility: data.resultVisibility,
allowComments: data.allowComments,
closesAt: data.closesAt ? new Date(data.closesAt) : undefined,
closeThreshold: data.closeThreshold,
isPrivate: data.isPrivate,
createdByUserId: userId,
options: { create: options },
},
select: pollDetailSelect,
});
// Schedule auto-close if closesAt is set
if (poll.closesAt) {
try {
const { pollAutoCloseQueueService } = await import('../../services/poll-auto-close-queue.service');
const jobId = await pollAutoCloseQueueService.scheduleJob(poll.id, poll.closesAt);
if (jobId) {
await prisma.strawPoll.update({ where: { id: poll.id }, data: { autoCloseJobId: jobId } });
}
} catch (err) {
logger.error('Failed to schedule auto-close job', { error: err, pollId: poll.id });
}
}
return poll;
}
async update(id: string, data: UpdateStrawPollInput) {
const existing = await prisma.strawPoll.findUnique({ where: { id } });
if (!existing) return null;
// Handle options update for SINGLE_CHOICE polls
if (data.options && existing.type === StrawPollType.SINGLE_CHOICE) {
// Delete removed options, update existing, create new
const existingOptions = await prisma.strawPollOption.findMany({ where: { pollId: id } });
const incomingIds = data.options.filter(o => o.id).map(o => o.id!);
const toDelete = existingOptions.filter(o => !incomingIds.includes(o.id));
await prisma.$transaction([
// Delete removed options (and their votes)
...toDelete.map(o => prisma.strawPollOption.delete({ where: { id: o.id } })),
// Upsert remaining
...data.options.map((opt, i) =>
opt.id
? prisma.strawPollOption.update({ where: { id: opt.id }, data: { label: opt.label, sortOrder: i } })
: prisma.strawPollOption.create({ data: { pollId: id, label: opt.label, sortOrder: i } })
),
]);
}
const { options: _, ...updateData } = data;
const poll = await prisma.strawPoll.update({
where: { id },
data: {
...updateData,
closesAt: data.closesAt === null ? null : data.closesAt ? new Date(data.closesAt) : undefined,
},
select: pollDetailSelect,
});
// Reschedule auto-close if closesAt changed
if (data.closesAt !== undefined) {
try {
const { pollAutoCloseQueueService } = await import('../../services/poll-auto-close-queue.service');
if (existing.autoCloseJobId) await pollAutoCloseQueueService.cancelJob(existing.id);
if (poll.closesAt) {
const jobId = await pollAutoCloseQueueService.scheduleJob(poll.id, poll.closesAt);
if (jobId) await prisma.strawPoll.update({ where: { id }, data: { autoCloseJobId: jobId } });
}
} catch (err) {
logger.error('Failed to reschedule auto-close job', { error: err, pollId: id });
}
}
return poll;
}
async delete(id: string) {
const existing = await prisma.strawPoll.findUnique({ where: { id } });
if (!existing) return null;
// Cancel auto-close job if scheduled
if (existing.autoCloseJobId) {
try {
const { pollAutoCloseQueueService } = await import('../../services/poll-auto-close-queue.service');
await pollAutoCloseQueueService.cancelJob(existing.id);
} catch {}
}
return prisma.strawPoll.delete({ where: { id } });
}
// ===== Lifecycle transitions =====
async activate(id: string) {
return prisma.strawPoll.update({
where: { id, status: StrawPollStatus.DRAFT },
data: { status: StrawPollStatus.ACTIVE },
select: pollDetailSelect,
});
}
async closePoll(id: string) {
const poll = await prisma.strawPoll.updateMany({
where: { id, status: StrawPollStatus.ACTIVE },
data: { status: StrawPollStatus.CLOSED },
});
if (poll.count === 0) return null;
const closed = await prisma.strawPoll.findUnique({
where: { id },
select: { slug: true, title: true, votes: { select: { userId: true }, where: { userId: { not: null } } } },
});
// Broadcast poll closed via SSE
if (closed) {
pollSseService.broadcast(closed.slug, 'poll_closed', { pollId: id });
}
// Notify authenticated voters (fire-and-forget)
if (closed?.votes) {
this.notifyVotersPollClosed(id, closed.title, closed.votes.map(v => v.userId!)).catch(() => {});
}
return prisma.strawPoll.findUnique({ where: { id }, select: pollDetailSelect });
}
async reopenPoll(id: string) {
return prisma.strawPoll.update({
where: { id, status: StrawPollStatus.CLOSED },
data: { status: StrawPollStatus.ACTIVE },
select: pollDetailSelect,
});
}
async archivePoll(id: string) {
return prisma.strawPoll.update({
where: { id, status: StrawPollStatus.CLOSED },
data: { status: StrawPollStatus.ARCHIVED },
select: pollDetailSelect,
});
}
// ===== Public endpoints =====
async findAllPublic(filters: ListStrawPollsInput) {
const { page, limit, search } = filters;
const where: Prisma.StrawPollWhereInput = {
status: StrawPollStatus.ACTIVE,
isPrivate: false,
};
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } },
];
}
const [polls, total] = await Promise.all([
prisma.strawPoll.findMany({
where,
select: {
id: true,
slug: true,
title: true,
description: true,
type: true,
status: true,
closesAt: true,
createdAt: true,
_count: { select: { votes: true, options: true } },
createdBy: { select: { name: true } },
},
orderBy: { createdAt: 'desc' },
skip: (page - 1) * limit,
take: limit,
}),
prisma.strawPoll.count({ where }),
]);
return { polls, total, page, limit };
}
async findBySlugPublic(slug: string, userId?: string, voterToken?: string, voterIp?: string) {
const poll = await prisma.strawPoll.findUnique({
where: { slug },
select: publicPollSelect,
});
if (!poll) return null;
if (poll.isPrivate && !userId) {
// Return limited metadata for private polls when not authenticated
return {
id: poll.id,
slug: poll.slug,
title: poll.title,
type: poll.type,
status: poll.status,
isPrivate: true,
requiresAuth: true,
};
}
// Determine if results should be shown
const showResults = this.shouldShowResults(poll, userId, voterToken, voterIp);
// Strip vote counts if results are hidden
const options = poll.options.map(opt => ({
id: opt.id,
label: opt.label,
sortOrder: opt.sortOrder,
voteCount: showResults ? opt._count.votes : undefined,
}));
// Check if the requester has already voted
const hasVoted = await this.checkHasVoted(poll.id, userId, voterToken, voterIp);
return {
...poll,
options,
totalVotes: showResults ? poll._count.votes : undefined,
showResults,
hasVoted,
};
}
async findBySlugWidget(slug: string) {
const poll = await prisma.strawPoll.findUnique({
where: { slug },
select: {
id: true,
slug: true,
title: true,
type: true,
status: true,
resultVisibility: true,
identityMode: true,
options: {
select: { id: true, label: true, sortOrder: true, _count: { select: { votes: true } } },
orderBy: { sortOrder: 'asc' },
},
_count: { select: { votes: true } },
},
});
if (!poll) return null;
// Widget always returns counts for LIVE/PUBLIC_ALWAYS; otherwise omit
const showCounts = poll.resultVisibility === 'LIVE' || poll.resultVisibility === 'PUBLIC_ALWAYS';
return {
id: poll.id,
slug: poll.slug,
title: poll.title,
type: poll.type,
status: poll.status,
identityMode: poll.identityMode,
totalVotes: showCounts ? poll._count.votes : 0,
options: poll.options.map(o => ({
id: o.id,
label: o.label,
sortOrder: o.sortOrder,
voteCount: showCounts ? o._count.votes : 0,
})),
};
}
// ===== Voting =====
async submitVote(
slug: string,
data: SubmitStrawPollVoteInput,
userId?: string,
clientIp?: string,
) {
const poll = await prisma.strawPoll.findUnique({
where: { slug },
select: { id: true, status: true, identityMode: true, closeThreshold: true, slug: true, _count: { select: { votes: true } } },
});
if (!poll) throw new Error('Poll not found');
if (poll.status !== StrawPollStatus.ACTIVE) throw new Error('Poll is not active');
// Validate option exists
const option = await prisma.strawPollOption.findFirst({
where: { id: data.optionId, pollId: poll.id },
});
if (!option) throw new Error('Invalid option');
// Enforce identity mode
const { identityMode } = poll;
if (identityMode === 'AUTHENTICATED' && !userId) {
throw new Error('Authentication required to vote');
}
if (identityMode === 'TOKEN_GATED' && !data.voterToken) {
throw new Error('A voting token is required');
}
// Determine dedup key
let voterToken = data.voterToken || null;
const voterIp = (identityMode === 'ANONYMOUS' && !userId) ? clientIp : null;
// For anonymous/mixed without a token, generate one
if (!userId && !voterToken && identityMode !== 'TOKEN_GATED') {
voterToken = generateVoterToken();
}
// Upsert vote: one vote per poll per voter
let vote;
if (userId) {
vote = await prisma.strawPollVote.upsert({
where: { pollId_userId: { pollId: poll.id, userId } },
create: {
pollId: poll.id,
optionId: data.optionId,
userId,
voterName: data.voterName,
voterToken,
},
update: { optionId: data.optionId, updatedAt: new Date() },
});
} else if (voterToken) {
vote = await prisma.strawPollVote.upsert({
where: { pollId_voterToken: { pollId: poll.id, voterToken } },
create: {
pollId: poll.id,
optionId: data.optionId,
voterName: data.voterName,
voterToken,
voterIp,
},
update: { optionId: data.optionId, updatedAt: new Date() },
});
} else if (voterIp) {
vote = await prisma.strawPollVote.upsert({
where: { pollId_voterIp: { pollId: poll.id, voterIp } },
create: {
pollId: poll.id,
optionId: data.optionId,
voterName: data.voterName,
voterIp,
},
update: { optionId: data.optionId, updatedAt: new Date() },
});
} else {
throw new Error('Unable to identify voter');
}
// Get updated counts for SSE broadcast
const optionCounts = await prisma.strawPollOption.findMany({
where: { pollId: poll.id },
select: { id: true, _count: { select: { votes: true } } },
orderBy: { sortOrder: 'asc' },
});
const totalVotes = optionCounts.reduce((sum, o) => sum + o._count.votes, 0);
// Broadcast vote update via SSE
pollSseService.broadcast(poll.slug, 'vote_update', {
optionCounts: optionCounts.map(o => ({ optionId: o.id, count: o._count.votes })),
totalVotes,
});
// Check auto-close threshold
if (poll.closeThreshold && totalVotes >= poll.closeThreshold) {
this.closePoll(poll.id).catch(err =>
logger.error('Auto-close by threshold failed', { error: err, pollId: poll.id })
);
}
return { voteId: vote.id, voterToken: voterToken || undefined };
}
// ===== Comments =====
async addComment(slug: string, data: SubmitStrawPollCommentInput, userId?: string) {
const poll = await prisma.strawPoll.findUnique({
where: { slug },
select: { id: true, allowComments: true, slug: true },
});
if (!poll) throw new Error('Poll not found');
if (!poll.allowComments) throw new Error('Comments are disabled');
const comment = await prisma.strawPollComment.create({
data: {
pollId: poll.id,
userId,
authorName: data.authorName,
content: data.content,
},
select: { id: true, authorName: true, content: true, createdAt: true },
});
pollSseService.broadcast(poll.slug, 'comment_added', comment);
return comment;
}
async deleteComment(pollId: string, commentId: string) {
return prisma.strawPollComment.delete({ where: { id: commentId, pollId } });
}
// ===== Vote moderation =====
async deleteVote(pollId: string, voteId: string) {
return prisma.strawPollVote.delete({ where: { id: voteId, pollId } });
}
// ===== Challenges =====
async challengeFriend(pollId: string, challengerUserId: string, challengedUserId: string) {
const poll = await prisma.strawPoll.findUnique({ where: { id: pollId }, select: { slug: true, title: true } });
if (!poll) throw new Error('Poll not found');
const challenge = await prisma.strawPollChallenge.create({
data: { pollId, challengerUserId, challengedUserId },
});
// Send notification (fire-and-forget)
this.sendChallengeNotification(challengedUserId, challengerUserId, poll.slug, poll.title).catch(() => {});
return challenge;
}
// ===== TOKEN_GATED link generation =====
async generateVotingTokens(pollId: string, count: number) {
const poll = await prisma.strawPoll.findUnique({ where: { id: pollId }, select: { identityMode: true, slug: true } });
if (!poll) throw new Error('Poll not found');
if (poll.identityMode !== 'TOKEN_GATED') throw new Error('Poll is not token-gated');
const tokens: string[] = [];
for (let i = 0; i < count; i++) {
tokens.push(generateVoterToken());
}
return { tokens, slug: poll.slug };
}
// ===== Private helpers =====
private shouldShowResults(
poll: { resultVisibility: string; status: string; createdByUserId: string; id: string },
userId?: string,
voterToken?: string,
voterIp?: string,
): boolean {
switch (poll.resultVisibility) {
case 'LIVE':
case 'PUBLIC_ALWAYS':
return true;
case 'AFTER_CLOSE':
return poll.status === 'CLOSED' || poll.status === 'ARCHIVED';
case 'CREATOR_ONLY':
return !!userId && userId === poll.createdByUserId;
case 'AFTER_VOTE':
// Will be checked after hasVoted query — return true optimistically,
// actual filtering happens in findBySlugPublic
return true; // placeholder; refined by hasVoted check in caller
default:
return false;
}
}
private async checkHasVoted(pollId: string, userId?: string, voterToken?: string, voterIp?: string): Promise<boolean> {
if (userId) {
const vote = await prisma.strawPollVote.findUnique({ where: { pollId_userId: { pollId, userId } } });
return !!vote;
}
if (voterToken) {
const vote = await prisma.strawPollVote.findUnique({ where: { pollId_voterToken: { pollId, voterToken } } });
return !!vote;
}
if (voterIp) {
const vote = await prisma.strawPollVote.findUnique({ where: { pollId_voterIp: { pollId, voterIp } } });
return !!vote;
}
return false;
}
private async notifyVotersPollClosed(pollId: string, title: string, voterUserIds: string[]) {
try {
const { notificationService } = await import('../social/notification.service');
for (const userId of voterUserIds) {
await notificationService.createNotification(
userId,
'poll_closed' as any,
'Poll Closed',
`The poll "${title}" has closed. Check the results!`,
{ pollId },
);
}
} catch (err) {
logger.error('Failed to send poll closed notifications', { error: err, pollId });
}
}
private async sendChallengeNotification(
challengedUserId: string,
challengerUserId: string,
pollSlug: string,
pollTitle: string,
) {
try {
const challenger = await prisma.user.findUnique({ where: { id: challengerUserId }, select: { name: true } });
const { notificationService } = await import('../social/notification.service');
await notificationService.createNotification(
challengedUserId,
'poll_challenge' as any,
'Poll Challenge',
`${challenger?.name || 'Someone'} challenged you to vote on "${pollTitle}"`,
{ pollSlug, challengerUserId },
);
} catch (err) {
logger.error('Failed to send challenge notification', { error: err });
}
}
}
export const strawPollsService = new StrawPollsService();

View File

@ -58,6 +58,7 @@ export const updateSiteSettingsSchema = z.object({
enableMeet: z.boolean().optional(), enableMeet: z.boolean().optional(),
enableMeetingPlanner: z.boolean().optional(), enableMeetingPlanner: z.boolean().optional(),
enableTicketedEvents: z.boolean().optional(), enableTicketedEvents: z.boolean().optional(),
enablePolls: z.boolean().optional(),
enableSocialCalendar: z.boolean().optional(), enableSocialCalendar: z.boolean().optional(),
enableDocsCollaboration: z.boolean().optional(), enableDocsCollaboration: z.boolean().optional(),
requireEventApproval: z.boolean().optional(), requireEventApproval: z.boolean().optional(),

View File

@ -5,6 +5,7 @@ import { validate } from '../../../middleware/validate';
import { smsCampaignsService } from './sms-campaigns.service'; import { smsCampaignsService } from './sms-campaigns.service';
import { createSmsCampaignSchema, updateSmsCampaignSchema } from './sms-campaigns.schemas'; import { createSmsCampaignSchema, updateSmsCampaignSchema } from './sms-campaigns.schemas';
import { smsQueueService } from '../../../services/sms-queue.service'; import { smsQueueService } from '../../../services/sms-queue.service';
import { eventBus } from '../../../services/event-bus.service';
import { BROADCAST_ROLES } from '../../../utils/roles'; import { BROADCAST_ROLES } from '../../../utils/roles';
const router = Router(); const router = Router();
@ -66,7 +67,17 @@ router.delete('/:id', async (req, res, next) => {
// POST /api/sms/campaigns/:id/start — start sending // POST /api/sms/campaigns/:id/start — start sending
router.post('/:id/start', async (req, res, next) => { router.post('/:id/start', async (req, res, next) => {
try { try {
const campaign = await smsCampaignsService.findById(req.params.id as string);
const result = await smsCampaignsService.start(req.params.id as string); const result = await smsCampaignsService.start(req.params.id as string);
if (campaign) {
eventBus.publish('sms.campaign.started', {
campaignId: campaign.id,
title: campaign.name,
recipientCount: campaign.totalRecipients,
});
}
res.json(result); res.json(result);
} catch (err) { next(err); } } catch (err) { next(err); }
}); });

View File

@ -171,6 +171,11 @@ router.post('/:id/import-csv', async (req, res, next) => {
res.status(400).json({ error: 'CSV text is required in the "csv" field' }); res.status(400).json({ error: 'CSV text is required in the "csv" field' });
return; return;
} }
const MAX_CSV_SIZE = 5 * 1024 * 1024; // 5MB
if (csv.length > MAX_CSV_SIZE) {
res.status(400).json({ error: { message: 'CSV too large (max 5MB)', code: 'CSV_TOO_LARGE' } });
return;
}
const result = await smsContactsService.importCsv(req.params.id as string, csv, filename); const result = await smsContactsService.importCsv(req.params.id as string, csv, filename);
res.json(result); res.json(result);
} catch (err) { next(err); } } catch (err) { next(err); }

View File

@ -1,9 +1,14 @@
import { Router } from 'express'; import { Router } from 'express';
import { authenticate } from '../../../middleware/auth.middleware'; import { authenticate } from '../../../middleware/auth.middleware';
import { requireRole } from '../../../middleware/rbac.middleware'; import { requireRole } from '../../../middleware/rbac.middleware';
import { smsSendRateLimit } from '../../../middleware/rate-limit';
import { smsMessagesService } from './sms-messages.service'; import { smsMessagesService } from './sms-messages.service';
import { eventBus } from '../../../services/event-bus.service';
import { BROADCAST_ROLES } from '../../../utils/roles'; import { BROADCAST_ROLES } from '../../../utils/roles';
const MAX_SMS_LENGTH = 1600;
const PHONE_DIGITS_RE = /^\d{10,11}$/;
const router = Router(); const router = Router();
router.use(authenticate, requireRole(...BROADCAST_ROLES)); router.use(authenticate, requireRole(...BROADCAST_ROLES));
@ -32,14 +37,30 @@ router.get('/followups', async (_req, res, next) => {
}); });
// POST /api/sms/messages/send — send ad-hoc SMS // POST /api/sms/messages/send — send ad-hoc SMS
router.post('/send', async (req, res, next) => { router.post('/send', smsSendRateLimit, async (req, res, next) => {
try { try {
const { phone, message } = req.body as { phone?: string; message?: string }; const { phone, message } = req.body as { phone?: string; message?: string };
if (!phone || !message) { if (!phone || !message) {
res.status(400).json({ error: 'Phone and message are required' }); res.status(400).json({ error: { message: 'Phone and message are required', code: 'VALIDATION_ERROR' } });
return;
}
const digits = phone.replace(/\D/g, '');
if (!PHONE_DIGITS_RE.test(digits)) {
res.status(400).json({ error: { message: 'Invalid phone number format', code: 'VALIDATION_ERROR' } });
return;
}
if (message.length > MAX_SMS_LENGTH) {
res.status(400).json({ error: { message: `Message too long (max ${MAX_SMS_LENGTH} characters)`, code: 'VALIDATION_ERROR' } });
return; return;
} }
const result = await smsMessagesService.sendSingle(phone, message); const result = await smsMessagesService.sendSingle(phone, message);
eventBus.publish('sms.message.sent', {
messageId: result.id,
phone: result.phone,
body: result.message,
});
res.json(result); res.json(result);
} catch (err) { next(err); } } catch (err) { next(err); }
}); });

View File

@ -5,7 +5,7 @@ import { friendshipService } from './friendship.service';
/** A unified feed item representing any activity type */ /** A unified feed item representing any activity type */
export interface FeedItem { export interface FeedItem {
id: string; id: string;
type: 'shift_signup' | 'campaign_email' | 'canvass_session' | 'response_submitted' | 'impact_story' | 'volunteer_featured' | 'referral_completed' | 'challenge_completed'; type: 'shift_signup' | 'campaign_email' | 'canvass_session' | 'response_submitted' | 'impact_story' | 'volunteer_featured' | 'referral_completed' | 'challenge_completed' | 'poll_voted';
userId: string; userId: string;
userName: string | null; userName: string | null;
userEmail: string; userEmail: string;
@ -56,7 +56,7 @@ export const feedService = {
since.setDate(since.getDate() - FEED_MAX_AGE_DAYS); since.setDate(since.getDate() - FEED_MAX_AGE_DAYS);
// Query all activity types in parallel // Query all activity types in parallel
const [shiftSignups, campaignEmails, canvassSessions, responses, impactStories, spotlights, referrals, challenges] = await Promise.all([ const [shiftSignups, campaignEmails, canvassSessions, responses, impactStories, spotlights, referrals, challenges, pollVotes] = await Promise.all([
this.getShiftSignupActivities(visibleFriendIds, since), this.getShiftSignupActivities(visibleFriendIds, since),
this.getCampaignEmailActivities(visibleFriendIds, since), this.getCampaignEmailActivities(visibleFriendIds, since),
this.getCanvassSessionActivities(visibleFriendIds, since), this.getCanvassSessionActivities(visibleFriendIds, since),
@ -65,6 +65,7 @@ export const feedService = {
this.getSpotlightActivities(since), this.getSpotlightActivities(since),
this.getReferralActivities(visibleFriendIds, since), this.getReferralActivities(visibleFriendIds, since),
this.getChallengeActivities(since), this.getChallengeActivities(since),
this.getStrawPollVoteActivities(visibleFriendIds, since),
]); ]);
// Merge and sort by timestamp descending // Merge and sort by timestamp descending
@ -77,6 +78,7 @@ export const feedService = {
...spotlights, ...spotlights,
...referrals, ...referrals,
...challenges, ...challenges,
...pollVotes,
].sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); ].sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
// Cap total items // Cap total items
@ -362,4 +364,34 @@ export const feedService = {
}; };
}); });
}, },
async getStrawPollVoteActivities(userIds: string[], since: Date): Promise<FeedItem[]> {
const votes = await prisma.strawPollVote.findMany({
where: {
userId: { in: userIds },
createdAt: { gte: since },
},
include: {
user: { select: { id: true, name: true, email: true } },
poll: { select: { id: true, slug: true, title: true } },
option: { select: { label: true } },
},
orderBy: { createdAt: 'desc' },
take: 20,
});
return votes
.filter((v) => v.user)
.map((v) => ({
id: `poll_vote:${v.id}`,
type: 'poll_voted' as const,
userId: v.user!.id,
userName: v.user!.name,
userEmail: v.user!.email,
title: `Voted on "${v.poll.title}"`,
description: `Chose: ${v.option.label}`,
metadata: { pollId: v.poll.id, pollSlug: v.poll.slug },
timestamp: v.createdAt,
}));
},
}; };

View File

@ -3,6 +3,7 @@ import { requireRole } from '../../middleware/rbac.middleware';
import { INFLUENCE_ROLES } from '../../utils/roles'; import { INFLUENCE_ROLES } from '../../utils/roles';
import { impactStoriesService } from './impact-stories.service'; import { impactStoriesService } from './impact-stories.service';
import { createStorySchema, updateStorySchema, listStoriesSchema } from './impact-stories.schemas'; import { createStorySchema, updateStorySchema, listStoriesSchema } from './impact-stories.schemas';
import { eventBus } from '../../services/event-bus.service';
const router = Router(); const router = Router();
@ -42,6 +43,14 @@ router.post('/:id/publish', requireRole(...INFLUENCE_ROLES), async (req, res, ne
const story = await impactStoriesService.publish(req.params.id as string); const story = await impactStoriesService.publish(req.params.id as string);
// Fire-and-forget: notify participants // Fire-and-forget: notify participants
impactStoriesService.notifyParticipants(story.id).catch(() => {}); impactStoriesService.notifyParticipants(story.id).catch(() => {});
eventBus.publish('social.impact-story.published', {
storyId: story.id,
title: story.title,
authorUserId: story.createdByUserId || req.user!.id,
campaignId: story.campaignId ?? null,
});
res.json(story); res.json(story);
} catch (err) { } catch (err) {
next(err); next(err);

View File

@ -31,6 +31,18 @@ router.get('/campaigns/:campaignId/friends', async (req: Request, res: Response)
} }
}); });
/** GET /api/social/integration/straw-polls/:pollId/friends */
router.get('/straw-polls/:pollId/friends', async (req: Request, res: Response) => {
try {
const userId = req.user!.id;
const pollId = req.params.pollId as string;
const result = await integrationService.getFriendsInStrawPoll(userId, pollId);
res.json(result);
} catch (err: any) {
res.status(err.statusCode || 500).json({ error: { message: err.message } });
}
});
/** GET /api/social/integration/map/friends */ /** GET /api/social/integration/map/friends */
router.get('/map/friends', async (req: Request, res: Response) => { router.get('/map/friends', async (req: Request, res: Response) => {
try { try {

View File

@ -99,6 +99,38 @@ export const integrationService = {
}; };
}, },
/** Get friends who voted on a straw poll */
async getFriendsInStrawPoll(userId: string, pollId: string) {
const friendIds = await friendshipService.getFriendIds(userId);
if (friendIds.length === 0) return { friends: [], count: 0 };
const hiddenIds = await this.getHiddenActivityUserIds(friendIds);
const visibleIds = friendIds.filter((id) => !hiddenIds.has(id));
if (visibleIds.length === 0) return { friends: [], count: 0 };
const votes = await prisma.strawPollVote.findMany({
where: {
pollId,
userId: { in: visibleIds },
},
distinct: ['userId'],
include: {
user: { select: { id: true, name: true, email: true } },
},
});
return {
friends: votes
.filter((v) => v.user)
.map((v) => ({
id: v.user!.id,
name: v.user!.name,
email: v.user!.email,
})),
count: votes.filter((v) => v.user).length,
};
},
/** Helper: get user IDs that have showInFriendActivity disabled */ /** Helper: get user IDs that have showInFriendActivity disabled */
async getHiddenActivityUserIds(userIds: string[]): Promise<Set<string>> { async getHiddenActivityUserIds(userIds: string[]): Promise<Set<string>> {
const hidden = await prisma.privacySettings.findMany({ const hidden = await prisma.privacySettings.findMany({

View File

@ -25,6 +25,10 @@ const TYPE_TO_PREF: Record<string, string> = {
shift_cancelled: 'enableSystemUpdates', shift_cancelled: 'enableSystemUpdates',
canvass_session_summary: 'enableSystemUpdates', canvass_session_summary: 'enableSystemUpdates',
reengagement: 'enableSystemUpdates', reengagement: 'enableSystemUpdates',
// Straw poll notification types
poll_closed: 'enableSystemUpdates',
poll_results_available: 'enableSystemUpdates',
poll_challenge: 'enableFriendRequests',
}; };
export const notificationService = { export const notificationService = {

View File

@ -39,6 +39,30 @@ async function requireEventPermission(req: Request, _res: Response, next: NextFu
return next({ status: 403, message: 'Insufficient permissions' }); return next({ status: 403, message: 'Insufficient permissions' });
} }
/** Middleware: for :id routes, verify non-admin users own the event */
async function requireEventOwnership(req: Request, res: Response, next: NextFunction) {
const eventId = req.params.id as string;
if (!eventId) return next();
const userRoles = req.user!.roles || [req.user!.role];
const isAdmin = userRoles.some(r => EVENTS_ROLES.includes(r as UserRole));
if (isAdmin) return next();
const event = await prisma.ticketedEvent.findUnique({
where: { id: eventId },
select: { createdByUserId: true },
});
if (!event) {
res.status(404).json({ error: { message: 'Event not found', code: 'NOT_FOUND' } });
return;
}
if (event.createdByUserId !== req.user!.id) {
res.status(403).json({ error: { message: 'Forbidden', code: 'FORBIDDEN' } });
return;
}
next();
}
// All routes require auth + event permission // All routes require auth + event permission
router.use(authenticate, requireEventPermission); router.use(authenticate, requireEventPermission);
@ -73,7 +97,7 @@ router.post('/', validate(createEventSchema), async (req: Request, res: Response
}); });
// GET /:id — event detail // GET /:id — event detail
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => { router.get('/:id', requireEventOwnership, async (req: Request, res: Response, next: NextFunction) => {
try { try {
const event = await ticketedEventsService.findById(req.params.id as string); const event = await ticketedEventsService.findById(req.params.id as string);
res.json(event); res.json(event);
@ -144,7 +168,7 @@ router.post('/:id/complete', requireRole(...EVENTS_ROLES), async (req: Request,
// --- Meeting --- // --- Meeting ---
// POST /:id/meeting-token — generate moderator JWT for Jitsi // POST /:id/meeting-token — generate moderator JWT for Jitsi
router.post('/:id/meeting-token', async (req: Request, res: Response, next: NextFunction) => { router.post('/:id/meeting-token', requireEventOwnership, async (req: Request, res: Response, next: NextFunction) => {
try { try {
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: req.user!.id }, where: { id: req.user!.id },
@ -159,7 +183,7 @@ router.post('/:id/meeting-token', async (req: Request, res: Response, next: Next
// --- Tiers --- // --- Tiers ---
// POST /:id/tiers // POST /:id/tiers
router.post('/:id/tiers', validate(createTierSchema), async (req: Request, res: Response, next: NextFunction) => { router.post('/:id/tiers', requireEventOwnership, validate(createTierSchema), async (req: Request, res: Response, next: NextFunction) => {
try { try {
const tier = await ticketedEventsService.addTier(req.params.id as string, req.body); const tier = await ticketedEventsService.addTier(req.params.id as string, req.body);
res.status(201).json(tier); res.status(201).json(tier);
@ -167,7 +191,7 @@ router.post('/:id/tiers', validate(createTierSchema), async (req: Request, res:
}); });
// PUT /:id/tiers/:tierId // PUT /:id/tiers/:tierId
router.put('/:id/tiers/:tierId', validate(updateTierSchema), async (req: Request, res: Response, next: NextFunction) => { router.put('/:id/tiers/:tierId', requireEventOwnership, validate(updateTierSchema), async (req: Request, res: Response, next: NextFunction) => {
try { try {
const tier = await ticketedEventsService.updateTier( const tier = await ticketedEventsService.updateTier(
req.params.tierId as string, req.params.tierId as string,
@ -179,7 +203,7 @@ router.put('/:id/tiers/:tierId', validate(updateTierSchema), async (req: Request
}); });
// DELETE /:id/tiers/:tierId // DELETE /:id/tiers/:tierId
router.delete('/:id/tiers/:tierId', async (req: Request, res: Response, next: NextFunction) => { router.delete('/:id/tiers/:tierId', requireEventOwnership, async (req: Request, res: Response, next: NextFunction) => {
try { try {
await ticketedEventsService.deleteTier(req.params.tierId as string, req.params.id as string); await ticketedEventsService.deleteTier(req.params.tierId as string, req.params.id as string);
res.json({ success: true }); res.json({ success: true });
@ -189,7 +213,7 @@ router.delete('/:id/tiers/:tierId', async (req: Request, res: Response, next: Ne
// --- Tickets --- // --- Tickets ---
// GET /:id/tickets // GET /:id/tickets
router.get('/:id/tickets', async (req: Request, res: Response, next: NextFunction) => { router.get('/:id/tickets', requireEventOwnership, async (req: Request, res: Response, next: NextFunction) => {
try { try {
const page = parseInt(req.query.page as string) || 1; const page = parseInt(req.query.page as string) || 1;
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
@ -201,7 +225,7 @@ router.get('/:id/tickets', async (req: Request, res: Response, next: NextFunctio
}); });
// GET /:id/checkins // GET /:id/checkins
router.get('/:id/checkins', async (req: Request, res: Response, next: NextFunction) => { router.get('/:id/checkins', requireEventOwnership, async (req: Request, res: Response, next: NextFunction) => {
try { try {
const page = parseInt(req.query.page as string) || 1; const page = parseInt(req.query.page as string) || 1;
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100); const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
@ -211,7 +235,7 @@ router.get('/:id/checkins', async (req: Request, res: Response, next: NextFuncti
}); });
// GET /:id/stats // GET /:id/stats
router.get('/:id/stats', async (req: Request, res: Response, next: NextFunction) => { router.get('/:id/stats', requireEventOwnership, async (req: Request, res: Response, next: NextFunction) => {
try { try {
const stats = await ticketedEventsService.getEventStats(req.params.id as string); const stats = await ticketedEventsService.getEventStats(req.params.id as string);
res.json(stats); res.json(stats);
@ -219,7 +243,7 @@ router.get('/:id/stats', async (req: Request, res: Response, next: NextFunction)
}); });
// POST /:id/resend-ticket/:ticketId // POST /:id/resend-ticket/:ticketId
router.post('/:id/resend-ticket/:ticketId', async (req: Request, res: Response, next: NextFunction) => { router.post('/:id/resend-ticket/:ticketId', requireEventOwnership, async (req: Request, res: Response, next: NextFunction) => {
try { try {
const ticket = await prisma.ticket.findUnique({ const ticket = await prisma.ticket.findUnique({
where: { id: req.params.ticketId as string }, where: { id: req.params.ticketId as string },
@ -273,7 +297,7 @@ router.post('/:id/resend-ticket/:ticketId', async (req: Request, res: Response,
}); });
// POST /:id/tickets/:ticketId/cancel // POST /:id/tickets/:ticketId/cancel
router.post('/:id/tickets/:ticketId/cancel', async (req: Request, res: Response, next: NextFunction) => { router.post('/:id/tickets/:ticketId/cancel', requireEventOwnership, async (req: Request, res: Response, next: NextFunction) => {
try { try {
await ticketsService.cancelTicket(req.params.ticketId as string); await ticketsService.cancelTicket(req.params.ticketId as string);
res.json({ success: true }); res.json({ success: true });

View File

@ -9,6 +9,8 @@ import { getStripe } from '../../services/stripe.client';
import { prisma } from '../../config/database'; import { prisma } from '../../config/database';
import { env } from '../../config/env'; import { env } from '../../config/env';
import { AppError } from '../../middleware/error-handler'; import { AppError } from '../../middleware/error-handler';
import { paymentCheckoutRateLimit } from '../../middleware/rate-limit';
import { requirePaymentsEnabled } from '../payments/payment-settings.service';
const router = Router(); const router = Router();
@ -101,7 +103,7 @@ router.get('/:slug/availability', async (req: Request, res: Response, next: Next
}); });
// POST /:slug/checkout — create Stripe checkout for paid ticket // POST /:slug/checkout — create Stripe checkout for paid ticket
router.post('/:slug/checkout', optionalAuth, validate(checkoutSchema), async (req: Request, res: Response, next: NextFunction) => { router.post('/:slug/checkout', requirePaymentsEnabled, paymentCheckoutRateLimit, optionalAuth, validate(checkoutSchema), async (req: Request, res: Response, next: NextFunction) => {
try { try {
const slug = req.params.slug as string; const slug = req.params.slug as string;
const { tierId, quantity, buyerEmail, buyerName } = req.body; const { tierId, quantity, buyerEmail, buyerName } = req.body;

View File

@ -1,6 +1,5 @@
import { prisma } from '../../config/database'; import { prisma } from '../../config/database';
import { TicketedEventStatus, TicketedEventVisibility, EventFormat, Prisma } from '@prisma/client'; import { TicketedEventStatus, TicketedEventVisibility, EventFormat, Prisma } from '@prisma/client';
import { logger } from '../../utils/logger';
import { AppError } from '../../middleware/error-handler'; import { AppError } from '../../middleware/error-handler';
import { unifiedCalendarService } from '../events/unified-calendar.service'; import { unifiedCalendarService } from '../events/unified-calendar.service';
import { siteSettingsService } from '../settings/settings.service'; import { siteSettingsService } from '../settings/settings.service';
@ -9,6 +8,7 @@ import { generateSlug as generateMeetingSlug } from '../../utils/slug';
import { env } from '../../config/env'; import { env } from '../../config/env';
import crypto from 'crypto'; import crypto from 'crypto';
import { EVENTS_ROLES } from '../../utils/roles'; import { EVENTS_ROLES } from '../../utils/roles';
import { eventBus } from '../../services/event-bus.service';
function generateSlug(title: string): string { function generateSlug(title: string): string {
return title return title
@ -384,10 +384,19 @@ export const ticketedEventsService = {
include: { ticketTiers: true }, include: { ticketTiers: true },
}); });
// Gancio sync + calendar cache bust (fire-and-forget) // Calendar cache bust (fire-and-forget)
this.syncToGancio(updated).catch(() => {});
unifiedCalendarService.bustCache().catch(() => {}); unifiedCalendarService.bustCache().catch(() => {});
eventBus.publish('ticketed-event.published', {
eventId: updated.id,
title: updated.title,
date: updated.date.toISOString().split('T')[0],
startTime: updated.startTime,
endTime: updated.endTime,
location: updated.venueAddress || updated.venueName || undefined,
gancioEventId: updated.gancioEventId ?? undefined,
});
return updated; return updated;
}, },
@ -404,8 +413,18 @@ export const ticketedEventsService = {
include: { ticketTiers: true }, include: { ticketTiers: true },
}); });
this.syncToGancio(updated).catch(() => {});
unifiedCalendarService.bustCache().catch(() => {}); unifiedCalendarService.bustCache().catch(() => {});
eventBus.publish('ticketed-event.published', {
eventId: updated.id,
title: updated.title,
date: updated.date.toISOString().split('T')[0],
startTime: updated.startTime,
endTime: updated.endTime,
location: updated.venueAddress || updated.venueName || undefined,
gancioEventId: updated.gancioEventId ?? undefined,
});
return updated; return updated;
}, },
@ -445,12 +464,14 @@ export const ticketedEventsService = {
data: { status: 'CANCELLED' }, data: { status: 'CANCELLED' },
}); });
// Delete from Gancio if synced + bust calendar cache // Calendar cache bust (fire-and-forget)
if (event.gancioEventId) {
this.deleteFromGancio(event.gancioEventId).catch(() => {});
}
unifiedCalendarService.bustCache().catch(() => {}); unifiedCalendarService.bustCache().catch(() => {});
eventBus.publish('ticketed-event.cancelled', {
eventId: updated.id,
title: event.title,
});
return updated; return updated;
}, },
@ -485,7 +506,10 @@ export const ticketedEventsService = {
} }
if (event.gancioEventId) { if (event.gancioEventId) {
this.deleteFromGancio(event.gancioEventId).catch(() => {}); eventBus.publish('ticketed-event.cancelled', {
eventId: event.id,
title: event.title,
});
} }
await prisma.ticketedEvent.delete({ where: { id } }); await prisma.ticketedEvent.delete({ where: { id } });
@ -767,78 +791,4 @@ export const ticketedEventsService = {
}; };
}, },
// --- Gancio Sync ---
async syncToGancio(event: {
id: string;
title: string;
description?: string | null;
venueAddress?: string | null;
venueName?: string | null;
eventFormat?: EventFormat;
date: Date;
startTime: string;
endTime: string;
gancioEventId?: number | null;
}) {
try {
const { gancioClient } = await import('../../services/gancio.client');
if (!gancioClient.enabled) return;
// Determine location based on event format
let location: string | null;
const format = event.eventFormat || 'IN_PERSON';
if (format === 'ONLINE') {
location = 'Online Event';
} else if (format === 'HYBRID') {
const venue = event.venueAddress || event.venueName || '';
location = venue ? `${venue} (also streaming online)` : 'Online + In-Person';
} else {
location = event.venueAddress || event.venueName || null;
}
const tags = ['ticketed', 'community'];
if (format === 'ONLINE') tags.push('online');
if (format === 'HYBRID') tags.push('hybrid');
if (event.gancioEventId) {
await gancioClient.updateEvent(event.gancioEventId, {
title: event.title,
description: event.description,
location,
date: event.date,
startTime: event.startTime,
endTime: event.endTime,
});
} else {
const gancioId = await gancioClient.createEvent({
title: event.title,
description: event.description,
location,
date: event.date,
startTime: event.startTime,
endTime: event.endTime,
tags,
});
if (gancioId) {
await prisma.ticketedEvent.update({
where: { id: event.id },
data: { gancioEventId: gancioId },
});
}
}
} catch (err) {
logger.warn('Gancio sync failed for ticketed event:', err instanceof Error ? err.message : err);
}
},
async deleteFromGancio(gancioEventId: number) {
try {
const { gancioClient } = await import('../../services/gancio.client');
if (!gancioClient.enabled) return;
await gancioClient.deleteEvent(gancioEventId);
} catch (err) {
logger.warn(`Gancio delete failed for event ${gancioEventId}:`, err instanceof Error ? err.message : err);
}
},
}; };

View File

@ -10,6 +10,7 @@ import { requireRole } from '../../middleware/rbac.middleware';
import { hasAnyRole, ADMIN_ROLES, getUserRoles } from '../../utils/roles'; import { hasAnyRole, ADMIN_ROLES, getUserRoles } from '../../utils/roles';
import { prisma } from '../../config/database'; import { prisma } from '../../config/database';
import { emailService } from '../../services/email.service'; import { emailService } from '../../services/email.service';
import { eventBus } from '../../services/event-bus.service';
import { env } from '../../config/env'; import { env } from '../../config/env';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
import { userProvisioningService } from '../../services/user-provisioning/provisioning.service'; import { userProvisioningService } from '../../services/user-provisioning/provisioning.service';
@ -115,7 +116,7 @@ router.put(
} }
// Self-service password change requires current password verification // Self-service password change requires current password verification
if (isSelf && !isAdminUser && req.body.password) { if (isSelf && req.body.password) {
if (!req.body.currentPassword) { if (!req.body.currentPassword) {
res.status(400).json({ error: { message: 'Current password is required to change your password', code: 'CURRENT_PASSWORD_REQUIRED' } }); res.status(400).json({ error: { message: 'Current password is required to change your password', code: 'CURRENT_PASSWORD_REQUIRED' } });
return; return;
@ -183,6 +184,14 @@ router.post(
roles: user.roles, status: 'ACTIVE', permissions: user.permissions as Record<string, unknown> | null, roles: user.roles, status: 'ACTIVE', permissions: user.permissions as Record<string, unknown> | null,
}).catch(err => logger.warn('User provisioning hook (approve) failed:', err)); }).catch(err => logger.warn('User provisioning hook (approve) failed:', err));
eventBus.publish('user.approved', {
userId: user.id,
email: user.email,
name: user.name || '',
role: user.role,
approvedByUserId: req.user!.id,
});
res.json({ message: 'User approved', userId: id }); res.json({ message: 'User approved', userId: id });
} catch (err) { } catch (err) {
next(err); next(err);

View File

@ -4,6 +4,7 @@ import { prisma } from '../../config/database';
import { AppError } from '../../middleware/error-handler'; import { AppError } from '../../middleware/error-handler';
import { getPrimaryRole } from '../../utils/roles'; import { getPrimaryRole } from '../../utils/roles';
import { userProvisioningService } from '../../services/user-provisioning/provisioning.service'; import { userProvisioningService } from '../../services/user-provisioning/provisioning.service';
import { eventBus } from '../../services/event-bus.service';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
import type { CMUser } from '../../services/user-provisioning/provisioner.interface'; import type { CMUser } from '../../services/user-provisioning/provisioner.interface';
import type { CreateUserInput, UpdateUserInput, ListUsersInput } from './users.schemas'; import type { CreateUserInput, UpdateUserInput, ListUsersInput } from './users.schemas';
@ -122,6 +123,13 @@ export const usersService = {
logger.warn('User provisioning hook (create) failed:', err); logger.warn('User provisioning hook (create) failed:', err);
}); });
eventBus.publish('user.created', {
userId: user.id,
email: user.email,
name: user.name || '',
role: user.role,
});
return user; return user;
}, },
@ -182,6 +190,16 @@ export const usersService = {
logger.warn('User provisioning hook (update) failed:', err); logger.warn('User provisioning hook (update) failed:', err);
}); });
// Compute list of changed fields for the event payload
const changes = Object.keys(data).filter(k => k !== 'currentPassword');
eventBus.publish('user.updated', {
userId: user.id,
email: user.email,
name: user.name || '',
role: user.role,
changes,
});
return user; return user;
}, },
@ -198,6 +216,12 @@ export const usersService = {
}); });
await prisma.user.delete({ where: { id } }); await prisma.user.delete({ where: { id } });
eventBus.publish('user.deleted', {
userId: existing.id,
email: existing.email,
name: existing.name || '',
});
}, },
}; };

View File

@ -14,6 +14,7 @@ import { authenticate } from './middleware/auth.middleware';
import { requireRole } from './middleware/rbac.middleware'; import { requireRole } from './middleware/rbac.middleware';
import { globalRateLimit, healthMetricsRateLimit } from './middleware/rate-limit'; import { globalRateLimit, healthMetricsRateLimit } from './middleware/rate-limit';
import { authRouter } from './modules/auth/auth.routes'; import { authRouter } from './modules/auth/auth.routes';
import { giteaSsoRouter } from './modules/auth/gitea-sso.routes';
import { usersRouter } from './modules/users/users.routes'; import { usersRouter } from './modules/users/users.routes';
import { provisioningRouter } from './modules/users/provisioning.routes'; import { provisioningRouter } from './modules/users/provisioning.routes';
import { campaignsRouter } from './modules/influence/campaigns/campaigns.routes'; import { campaignsRouter } from './modules/influence/campaigns/campaigns.routes';
@ -34,6 +35,9 @@ import { qrRouter } from './modules/qr/qr.routes';
import { listmonkRouter } from './modules/listmonk/listmonk.routes'; import { listmonkRouter } from './modules/listmonk/listmonk.routes';
import { listmonkWebhookRouter } from './modules/listmonk/listmonk-webhook.routes'; import { listmonkWebhookRouter } from './modules/listmonk/listmonk-webhook.routes';
import { meetingPlannerAdminRouter, meetingPlannerPublicRouter } from './modules/meeting-planner/meeting-planner.routes'; import { meetingPlannerAdminRouter, meetingPlannerPublicRouter } from './modules/meeting-planner/meeting-planner.routes';
import { strawPollAdminRouter } from './modules/polls/polls.routes';
import { strawPollPublicRouter } from './modules/polls/polls-public.routes';
import { strawPollWidgetRouter } from './modules/polls/polls-widget.routes';
import { pagesPublicRouter } from './modules/pages/pages-public.routes'; import { pagesPublicRouter } from './modules/pages/pages-public.routes';
import { pagesAdminRouter } from './modules/pages/pages-admin.routes'; import { pagesAdminRouter } from './modules/pages/pages-admin.routes';
import { blocksRouter } from './modules/pages/blocks.routes'; import { blocksRouter } from './modules/pages/blocks.routes';
@ -123,12 +127,16 @@ import { autoUpgradeService } from './services/auto-upgrade.service';
import { calendarFeedQueueService } from './services/calendar-feed-queue.service'; import { calendarFeedQueueService } from './services/calendar-feed-queue.service';
import { scheduledJobsQueueService } from './services/scheduled-jobs-queue.service'; import { scheduledJobsQueueService } from './services/scheduled-jobs-queue.service';
import { pollAutoFinalizeQueueService } from './services/poll-auto-finalize-queue.service'; import { pollAutoFinalizeQueueService } from './services/poll-auto-finalize-queue.service';
import { pollAutoCloseQueueService } from './services/poll-auto-close-queue.service';
import { pollSseService } from './modules/polls/polls-sse.service';
import { agendaRouter } from './modules/meetings/agenda.routes'; import { agendaRouter } from './modules/meetings/agenda.routes';
import { actionItemsRouter } from './modules/meetings/action-items.routes'; import { actionItemsRouter } from './modules/meetings/action-items.routes';
import { WebSocketServer } from 'ws'; import { WebSocketServer } from 'ws';
import { docsCollabService } from './modules/docs/docs-collab.service'; import { docsCollabService } from './modules/docs/docs-collab.service';
import { correlationId } from './middleware/correlation-id'; import { correlationId } from './middleware/correlation-id';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
import { registerAllEventListeners } from './services/event-listeners';
import { eventBus } from './services/event-bus.service';
const app = express(); const app = express();
@ -272,6 +280,7 @@ app.get('/api/metrics/internal', async (req, res) => {
// --- API Routes --- // --- API Routes ---
app.use('/api/auth', authRouter); app.use('/api/auth', authRouter);
app.use('/api/auth', giteaSsoRouter); // Gitea SSO validation (nginx auth_request)
app.use('/api/users', usersRouter); app.use('/api/users', usersRouter);
app.use('/api/users', provisioningRouter); // User provisioning management (ADMIN roles) app.use('/api/users', provisioningRouter); // User provisioning management (ADMIN roles)
app.use('/api/campaigns', campaignPublicRouter); // Public campaign details (no auth) app.use('/api/campaigns', campaignPublicRouter); // Public campaign details (no auth)
@ -301,6 +310,9 @@ app.use('/api/map/settings', mapSettingsRouter); // Map settings (public
app.use('/api/map/events', eventsPublicRouter); // Public map events from Gancio (no auth) app.use('/api/map/events', eventsPublicRouter); // Public map events from Gancio (no auth)
app.use('/api/meeting-planner', meetingPlannerPublicRouter); // Public poll viewing + voting (no auth) app.use('/api/meeting-planner', meetingPlannerPublicRouter); // Public poll viewing + voting (no auth)
app.use('/api/meeting-planner', meetingPlannerAdminRouter); // Admin poll CRUD (auth required) app.use('/api/meeting-planner', meetingPlannerAdminRouter); // Admin poll CRUD (auth required)
app.use('/api/straw-polls', strawPollPublicRouter); // Public straw poll voting + viewing (no auth)
app.use('/api/straw-polls', strawPollWidgetRouter); // Straw poll widget endpoint (no auth, cached)
app.use('/api/straw-polls', strawPollAdminRouter); // Admin straw poll CRUD (auth required)
app.use('/api/meetings/agendas', agendaRouter); // Meeting agendas + minutes (EVENTS roles) app.use('/api/meetings/agendas', agendaRouter); // Meeting agendas + minutes (EVENTS roles)
app.use('/api/meetings/action-items', actionItemsRouter); // Action items CRUD (EVENTS roles / auth) app.use('/api/meetings/action-items', actionItemsRouter); // Action items CRUD (EVENTS roles / auth)
app.use('/api/qr', qrRouter); // QR code generation (public) app.use('/api/qr', qrRouter); // QR code generation (public)
@ -390,6 +402,9 @@ async function start() {
// Register user provisioning framework // Register user provisioning framework
registerProvisioners(); registerProvisioners();
// Register EventBus listeners (Listmonk, RC, CRM, Calendar, n8n, Gancio)
registerAllEventListeners();
// Rebuild SMTP transporter from DB settings (env fallback for empty fields) // Rebuild SMTP transporter from DB settings (env fallback for empty fields)
await emailService.rebuildTransporter(); await emailService.rebuildTransporter();
@ -399,6 +414,7 @@ async function start() {
calendarFeedQueueService.startWorker(); calendarFeedQueueService.startWorker();
scheduledJobsQueueService.startWorker(); scheduledJobsQueueService.startWorker();
pollAutoFinalizeQueueService.startWorker(); pollAutoFinalizeQueueService.startWorker();
pollAutoCloseQueueService.startWorker();
startProxy(); startProxy();
// Load SMS config from DB (env fallback for empty fields) // Load SMS config from DB (env fallback for empty fields)
@ -432,6 +448,7 @@ async function start() {
// SSE + Presence: mark all users offline on startup, start heartbeat + stale cleanup // SSE + Presence: mark all users offline on startup, start heartbeat + stale cleanup
presenceService.markAllOffline().catch(() => {}); presenceService.markAllOffline().catch(() => {});
sseService.startHeartbeat(); sseService.startHeartbeat();
pollSseService.startHeartbeat();
setInterval(() => presenceService.cleanupStale().catch(() => {}), 60 * 1000); // every 1 min setInterval(() => presenceService.cleanupStale().catch(() => {}), 60 * 1000); // every 1 min
// Challenge lifecycle: activate/complete/score every 5 minutes // Challenge lifecycle: activate/complete/score every 5 minutes
@ -543,6 +560,7 @@ for (const signal of ['SIGTERM', 'SIGINT']) {
process.on(signal, async () => { process.on(signal, async () => {
logger.info(`${signal} received, shutting down...`); logger.info(`${signal} received, shutting down...`);
sseService.closeAll(); sseService.closeAll();
pollSseService.closeAll();
await docsCollabService.shutdown(); await docsCollabService.shutdown();
await stopProxy(); await stopProxy();
await emailQueueService.close(); await emailQueueService.close();

View File

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

View File

@ -0,0 +1,183 @@
/**
* Platform EventBus in-process pub/sub for decoupled service integration.
*
* Design:
* - Uses Node.js EventEmitter (single process, zero serialization overhead)
* - Typed events via PlatformEventMap (compile-time safety)
* - Wildcard subscriptions: subscribe('shift.*') catches all shift events
* - Error isolation: each listener wraps its handler in try-catch
* - Stats tracking: per-event and per-listener counters for observability
*
* Usage:
* // Publish (from any service)
* eventBus.publish('shift.signup.created', { shiftId, userName, ... });
*
* // Subscribe (from listeners registered at startup)
* eventBus.subscribe('shift.signup.created', async (payload) => { ... });
* eventBus.subscribe('shift.*', async (payload) => { ... }); // wildcard
*/
import { EventEmitter } from 'events';
import { logger } from '../utils/logger';
import type { PlatformEventMap, PlatformEventName, EventPayload } from '../types/events';
type EventHandler<E extends PlatformEventName> = (payload: EventPayload<E>) => void | Promise<void>;
interface ListenerRegistration {
name: string;
pattern: string;
handler: (event: string, payload: unknown) => void | Promise<void>;
}
interface EventStats {
published: number;
lastPublishedAt: Date | null;
}
class EventBus {
private emitter = new EventEmitter();
private listeners: ListenerRegistration[] = [];
private eventStats = new Map<string, EventStats>();
private listenerStats = new Map<string, { handled: number; errors: number }>();
constructor() {
// Allow many listeners (we'll have multiple per event)
this.emitter.setMaxListeners(100);
}
/**
* Publish a typed event. All matching subscribers are called asynchronously.
* This is fire-and-forget errors in listeners do NOT propagate to the publisher.
*/
publish<E extends PlatformEventName>(event: E, payload: EventPayload<E>): void {
// Update stats
const stats = this.eventStats.get(event) ?? { published: 0, lastPublishedAt: null };
stats.published++;
stats.lastPublishedAt = new Date();
this.eventStats.set(event, stats);
// Emit to exact subscribers
this.emitter.emit(event, payload);
// Emit to wildcard subscribers
for (const reg of this.listeners) {
if (reg.pattern.endsWith('.*')) {
const prefix = reg.pattern.slice(0, -2);
if (event.startsWith(prefix + '.') && event !== reg.pattern) {
this.safeCall(reg.name, () => reg.handler(event, payload));
}
}
}
logger.debug(`EventBus: ${event}`, { event });
}
/**
* Subscribe to a specific event with a named listener.
* The name is used for stats tracking and debugging.
*/
subscribe<E extends PlatformEventName>(
event: E,
name: string,
handler: EventHandler<E>,
): void {
const wrappedHandler = (payload: EventPayload<E>) => {
this.safeCall(name, () => handler(payload));
};
this.emitter.on(event, wrappedHandler);
this.listeners.push({
name,
pattern: event,
handler: (_event: string, payload: unknown) => handler(payload as EventPayload<E>),
});
this.listenerStats.set(name, { handled: 0, errors: 0 });
}
/**
* Subscribe to all events matching a wildcard pattern (e.g., 'shift.*').
* Handler receives both the event name and payload.
*/
subscribePattern(
pattern: string,
name: string,
handler: (event: string, payload: unknown) => void | Promise<void>,
): void {
this.listeners.push({ name, pattern, handler });
this.listenerStats.set(name, { handled: 0, errors: 0 });
}
/**
* Call a handler with error isolation and stats tracking.
*/
private safeCall(listenerName: string, fn: () => void | Promise<void>): void {
const stats = this.listenerStats.get(listenerName);
try {
const result = fn();
if (result instanceof Promise) {
result
.then(() => {
if (stats) stats.handled++;
})
.catch((err) => {
if (stats) {
stats.handled++;
stats.errors++;
}
logger.debug(`EventBus listener "${listenerName}" error:`, err);
});
} else {
if (stats) stats.handled++;
}
} catch (err) {
if (stats) {
stats.handled++;
stats.errors++;
}
logger.debug(`EventBus listener "${listenerName}" sync error:`, err);
}
}
/**
* Get stats for observability dashboard.
*/
getStats(): {
totalEventsPublished: number;
eventCounts: Record<string, { published: number; lastPublishedAt: string | null }>;
listenerCounts: Record<string, { handled: number; errors: number }>;
registeredListeners: { name: string; pattern: string }[];
} {
let total = 0;
const eventCounts: Record<string, { published: number; lastPublishedAt: string | null }> = {};
for (const [name, stats] of this.eventStats) {
total += stats.published;
eventCounts[name] = {
published: stats.published,
lastPublishedAt: stats.lastPublishedAt?.toISOString() ?? null,
};
}
const listenerCounts: Record<string, { handled: number; errors: number }> = {};
for (const [name, stats] of this.listenerStats) {
listenerCounts[name] = { ...stats };
}
return {
totalEventsPublished: total,
eventCounts,
listenerCounts,
registeredListeners: this.listeners.map(l => ({ name: l.name, pattern: l.pattern })),
};
}
/**
* Remove all listeners (for testing or shutdown).
*/
removeAllListeners(): void {
this.emitter.removeAllListeners();
this.listeners = [];
}
}
export const eventBus = new EventBus();

View File

@ -0,0 +1,268 @@
/**
* Calendar Sync EventBus Listener
*
* Auto-populates CalendarItems from Shifts, Meetings, and TicketedEvents.
* Creates items on a system "Platform Events" layer, giving volunteers a
* unified timeline of all scheduled activities.
*
* Uses the existing CalendarItem.sourceType + sourceId fields for tracking
* which external entity each calendar item came from.
*
* No feature guard always active if enableSocialCalendar is true (checked per-event).
*/
import { eventBus } from '../event-bus.service';
import { logger } from '../../utils/logger';
// Lazy-import prisma
let prismaPromise: ReturnType<typeof getPrisma> | null = null;
async function getPrisma() {
const { prisma } = await import('../../config/database');
return prisma;
}
function lazyPrisma() {
if (!prismaPromise) prismaPromise = getPrisma();
return prismaPromise;
}
/**
* Check if the social calendar feature is enabled in site settings.
*/
async function isCalendarEnabled(): Promise<boolean> {
try {
const prisma = await lazyPrisma();
const settings = await prisma.siteSettings.findFirst({ select: { enableSocialCalendar: true } });
return settings?.enableSocialCalendar ?? false;
} catch {
return false;
}
}
/**
* Find or create the system "Platform Events" calendar layer.
* Uses a well-known layer name so all sync items land in one place.
*/
async function getSystemLayer(userId: string): Promise<string | null> {
try {
const prisma = await lazyPrisma();
// Look for existing system layer for this user
const existing = await prisma.calendarLayer.findFirst({
where: { userId, name: 'Platform Events', layerType: 'SYSTEM' },
select: { id: true },
});
if (existing) return existing.id;
// Create a new one
const layer = await prisma.calendarLayer.create({
data: {
userId,
name: 'Platform Events',
color: '#3498db',
layerType: 'SYSTEM',
visibility: 'PRIVATE',
isEnabled: true,
},
});
return layer.id;
} catch (err) {
logger.debug('Calendar sync: failed to get/create system layer:', err);
return null;
}
}
/**
* Upsert a calendar item linked to an external source.
*/
async function upsertCalendarItem(
userId: string,
sourceType: string,
sourceId: string,
data: {
title: string;
date: string;
startTime: string;
endTime: string;
description?: string;
location?: string;
},
): Promise<void> {
try {
if (!(await isCalendarEnabled())) return;
const prisma = await lazyPrisma();
const layerId = await getSystemLayer(userId);
if (!layerId) return;
const dateObj = new Date(data.date + 'T00:00:00Z');
// Check if calendar item already exists for this source
const existing = await prisma.calendarItem.findFirst({
where: { sourceType: sourceType as any, sourceId },
select: { id: true },
});
if (existing) {
// Update existing item
await prisma.calendarItem.update({
where: { id: existing.id },
data: {
title: data.title,
date: dateObj,
startTime: data.startTime,
endTime: data.endTime,
description: data.description,
location: data.location,
},
});
} else {
// Create new item
await prisma.calendarItem.create({
data: {
userId,
layerId,
title: data.title,
date: dateObj,
startTime: data.startTime,
endTime: data.endTime,
description: data.description,
location: data.location,
sourceType: sourceType as any,
sourceId,
itemType: 'EVENT',
busyStatus: 'BUSY',
},
});
}
} catch (err) {
logger.debug(`Calendar sync: upsert failed for ${sourceType}:${sourceId}:`, err);
}
}
/**
* Delete a calendar item by its source reference.
*/
async function deleteBySource(sourceType: string, sourceId: string): Promise<void> {
try {
const prisma = await lazyPrisma();
await prisma.calendarItem.deleteMany({
where: { sourceType: sourceType as any, sourceId },
});
} catch (err) {
logger.debug(`Calendar sync: delete failed for ${sourceType}:${sourceId}:`, err);
}
}
export function registerCalendarSyncListener(): void {
// Shift created → Calendar item
eventBus.subscribe('shift.created', 'calendar:shift-created', async (payload) => {
await upsertCalendarItem(payload.createdByUserId, 'SHIFT', payload.shiftId, {
title: `Shift: ${payload.title}`,
date: payload.date,
startTime: payload.startTime,
endTime: payload.endTime,
location: payload.cutName ?? undefined,
});
});
// Shift updated → Update calendar item
eventBus.subscribe('shift.updated', 'calendar:shift-updated', async (payload) => {
// We don't know who created the shift — find existing calendar item
try {
const prisma = await lazyPrisma();
const existing = await prisma.calendarItem.findFirst({
where: { sourceId: payload.shiftId },
select: { userId: true },
});
if (!existing) return;
await upsertCalendarItem(existing.userId, 'SHIFT', payload.shiftId, {
title: `Shift: ${payload.title}`,
date: payload.date,
startTime: payload.startTime,
endTime: payload.endTime,
location: payload.cutName ?? undefined,
});
} catch {
// silent
}
});
// Shift deleted → Remove calendar item
eventBus.subscribe('shift.deleted', 'calendar:shift-deleted', async (payload) => {
await deleteBySource('SHIFT', payload.shiftId);
});
// Meeting created → Calendar item
eventBus.subscribe('meeting.created', 'calendar:meeting-created', async (payload) => {
const date = payload.scheduledAt.split('T')[0];
const time = payload.scheduledAt.split('T')[1]?.slice(0, 5) ?? '00:00';
const endHour = parseInt(time.split(':')[0]) + 1;
const endTime = `${String(endHour).padStart(2, '0')}:${time.split(':')[1]}`;
await upsertCalendarItem(payload.createdByUserId, 'MEETING', payload.meetingId, {
title: `Meeting: ${payload.title}`,
date,
startTime: time,
endTime,
description: payload.jitsiRoomName ? `Jitsi room: ${payload.jitsiRoomName}` : undefined,
});
});
// Meeting updated → Update calendar item
eventBus.subscribe('meeting.updated', 'calendar:meeting-updated', async (payload) => {
try {
const prisma = await lazyPrisma();
const existing = await prisma.calendarItem.findFirst({
where: { sourceId: payload.meetingId },
select: { userId: true },
});
if (!existing) return;
const date = payload.scheduledAt.split('T')[0];
const time = payload.scheduledAt.split('T')[1]?.slice(0, 5) ?? '00:00';
const endHour = parseInt(time.split(':')[0]) + 1;
const endTime = `${String(endHour).padStart(2, '0')}:${time.split(':')[1]}`;
await upsertCalendarItem(existing.userId, 'MEETING', payload.meetingId, {
title: `Meeting: ${payload.title}`,
date,
startTime: time,
endTime,
});
} catch {
// silent
}
});
// Meeting deleted → Remove calendar item
eventBus.subscribe('meeting.deleted', 'calendar:meeting-deleted', async (payload) => {
await deleteBySource('MEETING', payload.meetingId);
});
// Ticketed event published → Calendar item
eventBus.subscribe('ticketed-event.published', 'calendar:ticketed-event', async (payload) => {
// Find who created this event
try {
const prisma = await lazyPrisma();
const event = await prisma.ticketedEvent.findUnique({
where: { id: payload.eventId },
select: { createdByUserId: true },
});
if (!event) return;
await upsertCalendarItem(event.createdByUserId, 'TICKETED_EVENT', payload.eventId, {
title: `Event: ${payload.title}`,
date: payload.date,
startTime: payload.startTime,
endTime: payload.endTime ?? payload.startTime,
location: payload.location,
});
} catch {
// silent
}
});
// Ticketed event cancelled → Remove calendar item
eventBus.subscribe('ticketed-event.cancelled', 'calendar:ticketed-event-cancel', async (payload) => {
await deleteBySource('TICKETED_EVENT', payload.eventId);
});
}

View File

@ -0,0 +1,234 @@
/**
* CRM Activity EventBus Listener
*
* Auto-creates ContactActivity entries for every meaningful engagement touchpoint.
* This makes the CRM contact timeline actually useful staff can see a contact's
* full interaction history across campaigns, canvassing, donations, and SMS.
*
* No feature guard always active (activities are core CRM data).
*/
import { eventBus } from '../event-bus.service';
import { logger } from '../../utils/logger';
// Lazy-import prisma to avoid circular dependency at module load time
let prismaPromise: ReturnType<typeof getPrisma> | null = null;
async function getPrisma() {
const { prisma } = await import('../../config/database');
return prisma;
}
function lazyPrisma() {
if (!prismaPromise) prismaPromise = getPrisma();
return prismaPromise;
}
/**
* Find a contact by email. Returns null if not found or no email provided.
*/
async function findContactByEmail(email?: string | null): Promise<string | null> {
if (!email) return null;
try {
const prisma = await lazyPrisma();
const contactEmail = await prisma.contactEmail.findFirst({
where: { email: email.toLowerCase() },
select: { contactId: true },
});
return contactEmail?.contactId ?? null;
} catch {
return null;
}
}
/**
* Create a ContactActivity entry. Silently fails if contact not found.
*/
async function createActivity(
contactId: string,
type: string,
title: string,
description?: string,
metadata?: Record<string, unknown>,
): Promise<void> {
try {
const prisma = await lazyPrisma();
await prisma.contactActivity.create({
data: {
contactId,
type: type as any,
title,
description,
metadata: metadata ? (metadata as unknown as import('@prisma/client').Prisma.InputJsonValue) : undefined,
},
});
} catch (err) {
logger.debug(`CRM activity creation failed for contact ${contactId}:`, err);
}
}
export function registerCrmActivityListener(): void {
// Campaign email sent
eventBus.subscribe('campaign.email.sent', 'crm:campaign-email', async (payload) => {
const contactId = await findContactByEmail(payload.email);
if (!contactId) return;
await createActivity(contactId, 'EMAIL_SENT', `Sent advocacy email for "${payload.campaignSlug}"`, undefined, {
campaignSlug: payload.campaignSlug,
postalCode: payload.postalCode,
});
});
// Shift signup
eventBus.subscribe('shift.signup.created', 'crm:shift-signup', async (payload) => {
const contactId = await findContactByEmail(payload.userEmail);
if (!contactId) return;
await createActivity(contactId, 'SHIFT_SIGNUP', `Signed up for shift: ${payload.shiftTitle}`, undefined, {
shiftId: payload.shiftId,
shiftDate: payload.shiftDate,
signupType: payload.signupType,
});
});
// Canvass visit recorded
eventBus.subscribe('canvass.visit.recorded', 'crm:canvass-visit', async (payload) => {
const contactId = await findContactByEmail(payload.email);
if (!contactId) return;
await createActivity(contactId, 'CANVASS_VISIT', `Canvass visit: ${payload.outcome}`, undefined, {
visitId: payload.visitId,
outcome: payload.outcome,
supportLevel: payload.supportLevel,
});
});
// Response submitted
eventBus.subscribe('response.submitted', 'crm:response-submitted', async (payload) => {
const contactId = await findContactByEmail(payload.userEmail);
if (!contactId) return;
await createActivity(contactId, 'RESPONSE_SUBMITTED', `Submitted response for "${payload.campaignTitle}"`, undefined, {
responseId: payload.responseId,
campaignId: payload.campaignId,
representativeName: payload.representativeName,
});
});
// Donation completed
eventBus.subscribe('payment.donation.completed', 'crm:donation', async (payload) => {
const contactId = await findContactByEmail(payload.email);
if (!contactId) return;
const amount = (payload.amountCents / 100).toFixed(2);
await createActivity(contactId, 'DONATION', `Donated $${amount}`, undefined, {
orderId: payload.orderId,
amountCents: payload.amountCents,
});
});
// Product purchased
eventBus.subscribe('payment.product.purchased', 'crm:product-purchase', async (payload) => {
const contactId = await findContactByEmail(payload.email);
if (!contactId) return;
const amount = (payload.amountCents / 100).toFixed(2);
await createActivity(contactId, 'PURCHASE', `Purchased "${payload.productTitle}" ($${amount})`, undefined, {
orderId: payload.orderId,
productTitle: payload.productTitle,
amountCents: payload.amountCents,
});
});
// SMS sent
eventBus.subscribe('sms.message.sent', 'crm:sms-sent', async (payload) => {
// SMS uses phone numbers — find contact by phone
try {
const prisma = await lazyPrisma();
const contactPhone = await prisma.contactPhone.findFirst({
where: { phone: payload.phone },
select: { contactId: true },
});
if (!contactPhone) return;
await createActivity(contactPhone.contactId, 'SMS_SENT', `SMS sent to ${payload.phone}`, payload.body.slice(0, 200), {
messageId: payload.messageId,
campaignId: payload.campaignId,
});
} catch {
// silent
}
});
// SMS received
eventBus.subscribe('sms.message.received', 'crm:sms-received', async (payload) => {
try {
const prisma = await lazyPrisma();
const contactPhone = await prisma.contactPhone.findFirst({
where: { phone: payload.phone },
select: { contactId: true },
});
if (!contactPhone) return;
await createActivity(contactPhone.contactId, 'SMS_RECEIVED', `SMS received from ${payload.phone}`, payload.body.slice(0, 200), {
messageId: payload.messageId,
conversationId: payload.conversationId,
responseType: payload.responseType,
});
} catch {
// silent
}
});
// Video viewed (only logged-in users)
eventBus.subscribe('media.video.viewed', 'crm:video-view', async (payload) => {
if (!payload.userId) return;
try {
const prisma = await lazyPrisma();
const user = await prisma.user.findUnique({
where: { id: payload.userId },
select: { email: true },
});
if (!user) return;
const contactId = await findContactByEmail(user.email);
if (!contactId) return;
await createActivity(contactId, 'VIDEO_VIEW', `Watched "${payload.videoTitle}"`, undefined, {
videoId: payload.videoId,
});
} catch {
// silent
}
});
// Subscription activated
eventBus.subscribe('payment.subscription.activated', 'crm:subscription', async (payload) => {
const contactId = await findContactByEmail(payload.email);
if (!contactId) return;
await createActivity(contactId, 'PURCHASE', `Subscribed to "${payload.planName}"`, undefined, {
subscriptionId: payload.subscriptionId,
planName: payload.planName,
});
});
// Listmonk email bounced → flag contact
eventBus.subscribe('listmonk.email.bounced', 'crm:email-bounced', async (payload) => {
const contactId = await findContactByEmail(payload.subscriberEmail);
if (!contactId) return;
await createActivity(contactId, 'NOTE_ADDED', `Email bounced (${payload.bounceType})`, `Email address may be invalid — bounced on campaign #${payload.campaignId}`, {
listmonkCampaignId: payload.campaignId,
bounceType: payload.bounceType,
action: 'bounced',
});
});
// Listmonk email opened → activity
eventBus.subscribe('listmonk.email.opened', 'crm:email-opened', async (payload) => {
const contactId = await findContactByEmail(payload.subscriberEmail);
if (!contactId) return;
await createActivity(contactId, 'EMAIL_SENT', `Opened newsletter: "${payload.campaignName}"`, undefined, {
listmonkCampaignId: payload.campaignId,
action: 'opened',
});
});
// Listmonk email link clicked → activity
eventBus.subscribe('listmonk.email.clicked', 'crm:email-clicked', async (payload) => {
const contactId = await findContactByEmail(payload.subscriberEmail);
if (!contactId) return;
await createActivity(contactId, 'EMAIL_SENT', `Clicked link in "${payload.campaignName}"`, payload.url, {
listmonkCampaignId: payload.campaignId,
action: 'clicked',
url: payload.url,
});
});
}

View File

@ -0,0 +1,212 @@
/**
* Engagement Scoring EventBus Listener
*
* Maintains a real-time engagement score for each contact based on their
* activity across the platform. Scores are stored in Redis for fast access
* and a sorted set provides leaderboard queries.
*
* Scoring weights:
* Donation completed +50
* Subscription activated +40
* Product purchased +30
* Shift signup +20
* Canvass visit +15
* Response submitted +15
* Campaign email sent +10
* SMS received +10
* Email opened +5
* Email link clicked +8
* Video viewed +3
*
* Redis keys:
* engagement:score:{contactId} total score (string/integer)
* engagement:leaderboard sorted set (contactId score)
* engagement:last:{contactId} ISO timestamp of last activity
*
* No feature guard always active (scores are ephemeral in Redis).
*/
import { eventBus } from '../event-bus.service';
import { logger } from '../../utils/logger';
// Lazy-import to avoid circular dependency
let redisPromise: ReturnType<typeof getRedis> | null = null;
async function getRedis() {
const { redis } = await import('../../config/redis');
return redis;
}
function lazyRedis() {
if (!redisPromise) redisPromise = getRedis();
return redisPromise;
}
let prismaPromise: ReturnType<typeof getPrisma> | null = null;
async function getPrisma() {
const { prisma } = await import('../../config/database');
return prisma;
}
function lazyPrisma() {
if (!prismaPromise) prismaPromise = getPrisma();
return prismaPromise;
}
/** Find contactId by email. Returns null if not found. */
async function findContactByEmail(email?: string | null): Promise<string | null> {
if (!email) return null;
try {
const prisma = await lazyPrisma();
const row = await prisma.contactEmail.findFirst({
where: { email: email.toLowerCase() },
select: { contactId: true },
});
return row?.contactId ?? null;
} catch {
return null;
}
}
/** Find contactId by phone. Returns null if not found. */
async function findContactByPhone(phone: string): Promise<string | null> {
try {
const prisma = await lazyPrisma();
const row = await prisma.contactPhone.findFirst({
where: { phone },
select: { contactId: true },
});
return row?.contactId ?? null;
} catch {
return null;
}
}
/** Increment a contact's engagement score in Redis. */
async function addScore(contactId: string, points: number): Promise<void> {
try {
const redis = await lazyRedis();
const pipeline = redis.pipeline();
pipeline.incrby(`engagement:score:${contactId}`, points);
pipeline.zincrby('engagement:leaderboard', points, contactId);
pipeline.set(`engagement:last:${contactId}`, new Date().toISOString());
await pipeline.exec();
} catch (err) {
logger.debug(`Engagement scoring failed for ${contactId}:`, err);
}
}
export function registerEngagementScoringListener(): void {
// --- High-value actions ---
eventBus.subscribe('payment.donation.completed', 'engagement:donation', async (payload) => {
const contactId = await findContactByEmail(payload.email);
if (contactId) await addScore(contactId, 50);
});
eventBus.subscribe('payment.subscription.activated', 'engagement:subscription', async (payload) => {
const contactId = await findContactByEmail(payload.email);
if (contactId) await addScore(contactId, 40);
});
eventBus.subscribe('payment.product.purchased', 'engagement:purchase', async (payload) => {
const contactId = await findContactByEmail(payload.email);
if (contactId) await addScore(contactId, 30);
});
// --- Volunteer actions ---
eventBus.subscribe('shift.signup.created', 'engagement:shift-signup', async (payload) => {
const contactId = await findContactByEmail(payload.userEmail);
if (contactId) await addScore(contactId, 20);
});
eventBus.subscribe('canvass.visit.recorded', 'engagement:canvass-visit', async (payload) => {
const contactId = await findContactByEmail(payload.email);
if (contactId) await addScore(contactId, 15);
});
eventBus.subscribe('response.submitted', 'engagement:response', async (payload) => {
const contactId = await findContactByEmail(payload.userEmail);
if (contactId) await addScore(contactId, 15);
});
// --- Communication actions ---
eventBus.subscribe('campaign.email.sent', 'engagement:email-sent', async (payload) => {
const contactId = await findContactByEmail(payload.email);
if (contactId) await addScore(contactId, 10);
});
eventBus.subscribe('sms.message.received', 'engagement:sms-received', async (payload) => {
const contactId = await findContactByPhone(payload.phone);
if (contactId) await addScore(contactId, 10);
});
eventBus.subscribe('listmonk.email.clicked', 'engagement:email-clicked', async (payload) => {
const contactId = await findContactByEmail(payload.subscriberEmail);
if (contactId) await addScore(contactId, 8);
});
eventBus.subscribe('listmonk.email.opened', 'engagement:email-opened', async (payload) => {
const contactId = await findContactByEmail(payload.subscriberEmail);
if (contactId) await addScore(contactId, 5);
});
// --- Passive actions ---
eventBus.subscribe('media.video.viewed', 'engagement:video-view', async (payload) => {
if (!payload.userId) return;
try {
const prisma = await lazyPrisma();
const user = await prisma.user.findUnique({
where: { id: payload.userId },
select: { email: true },
});
if (!user) return;
const contactId = await findContactByEmail(user.email);
if (contactId) await addScore(contactId, 3);
} catch {
// silent
}
});
}
/**
* Utility functions for querying engagement scores.
* Can be imported by API routes for score display.
*/
export const engagementScoring = {
/** Get a single contact's score */
async getScore(contactId: string): Promise<number> {
try {
const redis = await lazyRedis();
const score = await redis.get(`engagement:score:${contactId}`);
return score ? parseInt(score, 10) : 0;
} catch {
return 0;
}
},
/** Get top N contacts by engagement score */
async getLeaderboard(limit = 20): Promise<Array<{ contactId: string; score: number }>> {
try {
const redis = await lazyRedis();
const results = await redis.zrevrange('engagement:leaderboard', 0, limit - 1, 'WITHSCORES');
const leaderboard: Array<{ contactId: string; score: number }> = [];
for (let i = 0; i < results.length; i += 2) {
leaderboard.push({ contactId: results[i], score: parseInt(results[i + 1], 10) });
}
return leaderboard;
} catch {
return [];
}
},
/** Get last activity timestamp for a contact */
async getLastActivity(contactId: string): Promise<string | null> {
try {
const redis = await lazyRedis();
return await redis.get(`engagement:last:${contactId}`);
} catch {
return null;
}
},
};

View File

@ -0,0 +1,190 @@
/**
* Gancio EventBus Listener
*
* Syncs shift and ticketed events to the Gancio public event calendar.
* This replaces the inline gancioClient calls in shifts.service.ts and
* ticketed-events.service.ts.
*
* Feature guard: GANCIO_SYNC_ENABLED=true (checked inside gancioClient)
*/
import { eventBus } from '../event-bus.service';
import { logger } from '../../utils/logger';
// Lazy-import to avoid circular dependency at module load
async function getGancioClient() {
const { gancioClient } = await import('../gancio.client');
return gancioClient;
}
export function registerGancioListener(): void {
// Shift created → Create Gancio event
eventBus.subscribe('shift.created', 'gancio:shift-created', async (payload) => {
try {
const gancio = await getGancioClient();
if (!gancio.enabled) return;
const eventId = await gancio.createEvent({
title: payload.title,
description: `Volunteer shift: ${payload.title}`,
location: payload.cutName ?? 'TBD',
date: new Date(payload.date),
startTime: payload.startTime,
endTime: payload.endTime,
tags: ['volunteer', 'shift'],
});
// Store gancioEventId back on the shift
if (eventId) {
const { prisma } = await import('../../config/database');
await prisma.shift.update({
where: { id: payload.shiftId },
data: { gancioEventId: eventId },
});
}
} catch (err) {
logger.debug('Gancio sync: shift create failed:', err);
}
});
// Shift updated → Update Gancio event
eventBus.subscribe('shift.updated', 'gancio:shift-updated', async (payload) => {
try {
const gancio = await getGancioClient();
if (!gancio.enabled) return;
const { prisma } = await import('../../config/database');
const shift = await prisma.shift.findUnique({
where: { id: payload.shiftId },
select: { gancioEventId: true },
});
if (!shift?.gancioEventId) return;
await gancio.updateEvent(shift.gancioEventId, {
title: payload.title,
description: `Volunteer shift: ${payload.title}`,
location: payload.cutName ?? 'TBD',
date: new Date(payload.date),
startTime: payload.startTime,
endTime: payload.endTime,
});
} catch (err) {
logger.debug('Gancio sync: shift update failed:', err);
}
});
// Shift deleted → Delete Gancio event
eventBus.subscribe('shift.deleted', 'gancio:shift-deleted', async (payload) => {
try {
const gancio = await getGancioClient();
if (!gancio.enabled) return;
const { prisma } = await import('../../config/database');
const shift = await prisma.shift.findUnique({
where: { id: payload.shiftId },
select: { gancioEventId: true },
});
if (!shift?.gancioEventId) return;
await gancio.deleteEvent(shift.gancioEventId);
} catch (err) {
logger.debug('Gancio sync: shift delete failed:', err);
}
});
// =========================================================================
// TICKETED EVENT LISTENERS
// =========================================================================
// Ticketed event published → Create or update Gancio event
eventBus.subscribe('ticketed-event.published', 'gancio:ticketed-event-published', async (payload) => {
try {
const gancio = await getGancioClient();
if (!gancio.enabled) return;
const { prisma } = await import('../../config/database');
const event = await prisma.ticketedEvent.findUnique({
where: { id: payload.eventId },
select: {
id: true,
title: true,
description: true,
venueAddress: true,
venueName: true,
eventFormat: true,
date: true,
startTime: true,
endTime: true,
gancioEventId: true,
},
});
if (!event) return;
// Determine location based on event format
const format = event.eventFormat || 'IN_PERSON';
let location: string | null;
if (format === 'ONLINE') {
location = 'Online Event';
} else if (format === 'HYBRID') {
const venue = event.venueAddress || event.venueName || '';
location = venue ? `${venue} (also streaming online)` : 'Online + In-Person';
} else {
location = event.venueAddress || event.venueName || null;
}
const tags = ['ticketed', 'community'];
if (format === 'ONLINE') tags.push('online');
if (format === 'HYBRID') tags.push('hybrid');
if (event.gancioEventId) {
// Update existing Gancio event
await gancio.updateEvent(event.gancioEventId, {
title: event.title,
description: event.description,
location,
date: event.date,
startTime: event.startTime,
endTime: event.endTime,
});
} else {
// Create new Gancio event and store ID back
const gancioId = await gancio.createEvent({
title: event.title,
description: event.description,
location,
date: event.date,
startTime: event.startTime,
endTime: event.endTime,
tags,
});
if (gancioId) {
await prisma.ticketedEvent.update({
where: { id: event.id },
data: { gancioEventId: gancioId },
});
}
}
} catch (err) {
logger.debug('Gancio sync: ticketed event publish failed:', err);
}
});
// Ticketed event cancelled → Delete Gancio event
eventBus.subscribe('ticketed-event.cancelled', 'gancio:ticketed-event-cancelled', async (payload) => {
try {
const gancio = await getGancioClient();
if (!gancio.enabled) return;
const { prisma } = await import('../../config/database');
const event = await prisma.ticketedEvent.findUnique({
where: { id: payload.eventId },
select: { gancioEventId: true },
});
if (!event?.gancioEventId) return;
await gancio.deleteEvent(event.gancioEventId);
} catch (err) {
logger.debug('Gancio sync: ticketed event cancel failed:', err);
}
});
}

View File

@ -0,0 +1,177 @@
/**
* Homepage Stats EventBus Listener
*
* Maintains real-time counters in Redis for the homepage dashboard and
* invalidates the homepage cache when underlying data changes.
*
* Redis keys:
* homepage:counter:emails total campaign emails sent
* homepage:counter:signups total shift signups
* homepage:counter:donations total donation count
* homepage:counter:donations:amt total donation amount (cents)
* homepage:counter:responses total campaign responses
* homepage:counter:canvass total canvass visits
* homepage:counter:videos total video views
* homepage:recent:{type} recent activity list (capped at 20)
*
* Cache invalidation:
* Deletes `homepage:public` when shifts, campaigns, or media change
* so the next request rebuilds with fresh data.
*
* No feature guard always active (counters are ephemeral in Redis).
*/
import { eventBus } from '../event-bus.service';
import { logger } from '../../utils/logger';
let redisPromise: ReturnType<typeof getRedis> | null = null;
async function getRedis() {
const { redis } = await import('../../config/redis');
return redis;
}
function lazyRedis() {
if (!redisPromise) redisPromise = getRedis();
return redisPromise;
}
const HOMEPAGE_CACHE_KEY = 'homepage:public';
/** Increment a counter and optionally invalidate the homepage cache. */
async function incrCounter(key: string, invalidateCache = false): Promise<void> {
try {
const redis = await lazyRedis();
await redis.incr(`homepage:counter:${key}`);
if (invalidateCache) await redis.del(HOMEPAGE_CACHE_KEY);
} catch (err) {
logger.debug(`Homepage counter increment failed (${key}):`, err);
}
}
/** Push a recent activity entry to a capped list. */
async function pushRecent(type: string, entry: Record<string, unknown>): Promise<void> {
try {
const redis = await lazyRedis();
const key = `homepage:recent:${type}`;
await redis.lpush(key, JSON.stringify({ ...entry, at: new Date().toISOString() }));
await redis.ltrim(key, 0, 19); // keep last 20
await redis.expire(key, 86400); // expire after 24h
} catch {
// silent
}
}
/** Invalidate homepage cache without incrementing anything. */
async function invalidateCache(): Promise<void> {
try {
const redis = await lazyRedis();
await redis.del(HOMEPAGE_CACHE_KEY);
} catch {
// silent
}
}
export function registerHomepageStatsListener(): void {
// --- Counter increments ---
eventBus.subscribe('campaign.email.sent', 'homepage:email-sent', async () => {
await incrCounter('emails');
});
eventBus.subscribe('shift.signup.created', 'homepage:shift-signup', async (payload) => {
await incrCounter('signups', true); // invalidate — signup count visible on homepage
await pushRecent('signups', { name: payload.userName, shift: payload.shiftTitle });
});
eventBus.subscribe('payment.donation.completed', 'homepage:donation', async (payload) => {
await incrCounter('donations');
try {
const redis = await lazyRedis();
await redis.incrby('homepage:counter:donations:amt', payload.amountCents);
} catch { /* silent */ }
await pushRecent('donations', { name: payload.name, amount: payload.amountCents });
});
eventBus.subscribe('response.submitted', 'homepage:response', async (payload) => {
await incrCounter('responses');
await pushRecent('responses', { campaign: payload.campaignTitle, rep: payload.representativeName });
});
eventBus.subscribe('canvass.visit.recorded', 'homepage:canvass', async () => {
await incrCounter('canvass');
});
eventBus.subscribe('media.video.viewed', 'homepage:video-view', async () => {
await incrCounter('videos');
});
// --- Cache invalidation (data visible on homepage changed) ---
eventBus.subscribe('shift.created', 'homepage:shift-changed', async () => {
await invalidateCache();
});
eventBus.subscribe('shift.deleted', 'homepage:shift-deleted', async () => {
await invalidateCache();
});
eventBus.subscribe('campaign.published', 'homepage:campaign-published', async () => {
await invalidateCache();
});
eventBus.subscribe('campaign.status.changed', 'homepage:campaign-status', async () => {
await invalidateCache();
});
eventBus.subscribe('media.video.published', 'homepage:video-published', async () => {
await invalidateCache();
});
eventBus.subscribe('ticketed-event.published', 'homepage:event-published', async () => {
await invalidateCache();
});
}
/**
* Utility functions for reading homepage stats from Redis.
* Can be imported by the homepage service for real-time counters.
*/
export const homepageStats = {
/** Get all counter values */
async getCounters(): Promise<Record<string, number>> {
try {
const redis = await lazyRedis();
const keys = [
'homepage:counter:emails',
'homepage:counter:signups',
'homepage:counter:donations',
'homepage:counter:donations:amt',
'homepage:counter:responses',
'homepage:counter:canvass',
'homepage:counter:videos',
];
const values = await redis.mget(...keys);
return {
totalEmailsSent: parseInt(values[0] ?? '0', 10),
totalShiftSignups: parseInt(values[1] ?? '0', 10),
totalDonations: parseInt(values[2] ?? '0', 10),
totalDonationAmountCents: parseInt(values[3] ?? '0', 10),
totalResponses: parseInt(values[4] ?? '0', 10),
totalCanvassVisits: parseInt(values[5] ?? '0', 10),
totalVideoViews: parseInt(values[6] ?? '0', 10),
};
} catch {
return {};
}
},
/** Get recent activity of a given type */
async getRecent(type: string, limit = 10): Promise<Array<Record<string, unknown>>> {
try {
const redis = await lazyRedis();
const items = await redis.lrange(`homepage:recent:${type}`, 0, limit - 1);
return items.map(item => JSON.parse(item));
} catch {
return [];
}
},
};

View File

@ -0,0 +1,41 @@
/**
* EventBus Listener Registry
*
* Registers all event listeners at application startup.
* Each listener is independent if one fails to register, others continue.
*/
import { logger } from '../../utils/logger';
import { registerListmonkListener } from './listmonk.listener';
import { registerRocketChatListener } from './rocketchat.listener';
import { registerCrmActivityListener } from './crm-activity.listener';
import { registerCalendarSyncListener } from './calendar-sync.listener';
import { registerN8nWebhookListener } from './n8n-webhook.listener';
import { registerGancioListener } from './gancio.listener';
import { registerEngagementScoringListener } from './engagement-scoring.listener';
import { registerHomepageStatsListener } from './homepage-stats.listener';
export function registerAllEventListeners(): void {
const listeners = [
{ name: 'Listmonk', register: registerListmonkListener },
{ name: 'Rocket.Chat', register: registerRocketChatListener },
{ name: 'CRM Activity', register: registerCrmActivityListener },
{ name: 'Calendar Sync', register: registerCalendarSyncListener },
{ name: 'n8n Webhook', register: registerN8nWebhookListener },
{ name: 'Gancio', register: registerGancioListener },
{ name: 'Engagement Scoring', register: registerEngagementScoringListener },
{ name: 'Homepage Stats', register: registerHomepageStatsListener },
];
let registered = 0;
for (const listener of listeners) {
try {
listener.register();
registered++;
} catch (err) {
logger.warn(`EventBus: failed to register ${listener.name} listener:`, err);
}
}
logger.info(`EventBus: ${registered}/${listeners.length} listeners registered`);
}

View File

@ -0,0 +1,105 @@
/**
* Listmonk EventBus Listener
*
* Subscribes to platform events and syncs subscribers to Listmonk newsletter lists.
* Replaces the inline listmonkEventSyncService calls scattered across service files.
*
* Feature guard: LISTMONK_SYNC_ENABLED=true
*/
import { eventBus } from '../event-bus.service';
import { listmonkEventSyncService } from '../listmonk-event-sync.service';
export function registerListmonkListener(): void {
// Shift signups → Volunteers list
eventBus.subscribe('shift.signup.created', 'listmonk:shift-signup', (payload) => {
listmonkEventSyncService.onShiftSignup({
email: payload.userEmail,
name: payload.userName,
shiftTitle: payload.shiftTitle,
shiftDate: payload.shiftDate,
cutName: payload.cutName ?? undefined,
});
});
// Canvass session completed → Canvassers list
eventBus.subscribe('canvass.session.completed', 'listmonk:canvass-completed', (payload) => {
listmonkEventSyncService.onCanvassSessionCompleted({
email: payload.userEmail,
name: payload.userName,
cutName: payload.cutName,
visitCount: payload.visitCount,
outcomes: payload.outcomes,
});
});
// Campaign email sent → Campaign Participants list
eventBus.subscribe('campaign.email.sent', 'listmonk:campaign-email', (payload) => {
listmonkEventSyncService.onCampaignEmailSent({
email: payload.email,
name: payload.name,
campaignSlug: payload.campaignSlug,
postalCode: payload.postalCode,
});
});
// Address updated (canvass visit) → Support level lists
eventBus.subscribe('contact.address.updated', 'listmonk:address-updated', (payload) => {
listmonkEventSyncService.onAddressUpdated({
email: payload.email,
name: payload.name,
supportLevel: payload.supportLevel,
sign: payload.sign,
address: payload.address,
});
});
// Subscription activated → Subscribers list
eventBus.subscribe('payment.subscription.activated', 'listmonk:subscription', (payload) => {
listmonkEventSyncService.onSubscriptionActivated({
email: payload.email,
name: payload.name,
planName: payload.planName,
subscriptionId: payload.subscriptionId,
});
});
// Donation completed → Donors list
eventBus.subscribe('payment.donation.completed', 'listmonk:donation', (payload) => {
listmonkEventSyncService.onDonationCompleted({
email: payload.email,
name: payload.name,
amountCents: payload.amountCents,
orderId: payload.orderId,
});
});
// Product purchased → Donors list
eventBus.subscribe('payment.product.purchased', 'listmonk:product-purchase', (payload) => {
listmonkEventSyncService.onProductPurchased({
email: payload.email,
name: payload.name,
productTitle: payload.productTitle,
amountCents: payload.amountCents,
orderId: payload.orderId,
});
});
// Contact tags changed → CRM tag lists
eventBus.subscribe('contact.tags.changed', 'listmonk:contact-tags', (payload) => {
listmonkEventSyncService.onContactTagsChanged({
email: payload.email,
name: payload.name,
addedTags: payload.addedTags,
removedTags: payload.removedTags,
});
});
// Reengagement sent → Volunteers list
eventBus.subscribe('reengagement.sent', 'listmonk:reengagement', (payload) => {
listmonkEventSyncService.onReengagementSent({
email: payload.email,
name: payload.name,
});
});
}

View File

@ -0,0 +1,55 @@
/**
* n8n Webhook EventBus Listener
*
* Forwards ALL platform events to n8n webhook endpoints.
* n8n workflows can filter events by type on the receiving end.
*
* Configuration:
* N8N_WEBHOOK_URLS comma-separated list of n8n webhook URLs to forward events to.
* Each URL receives all events; n8n workflows filter internally.
*
* Example .env:
* N8N_WEBHOOK_URLS=http://n8n-changemaker:5678/webhook/changemaker-events
*
* Feature guard: N8N_WEBHOOK_URLS must be set (non-empty).
*/
import { eventBus } from '../event-bus.service';
import { env } from '../../config/env';
import { logger } from '../../utils/logger';
function getWebhookUrls(): string[] {
const raw = (env as unknown as Record<string, string>).N8N_WEBHOOK_URLS || '';
if (!raw) return [];
return raw.split(',').map(u => u.trim()).filter(Boolean);
}
async function forwardToN8n(event: string, payload: unknown): Promise<void> {
const urls = getWebhookUrls();
if (urls.length === 0) return;
for (const url of urls) {
try {
await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
event,
payload,
timestamp: new Date().toISOString(),
source: 'changemaker-lite',
}),
signal: AbortSignal.timeout(5000),
});
} catch (err) {
logger.debug(`n8n webhook delivery failed for ${event}${url}:`, err);
}
}
}
export function registerN8nWebhookListener(): void {
// Subscribe to ALL events using wildcard pattern
eventBus.subscribePattern('*', 'n8n:webhook-emitter', (event, payload) => {
forwardToN8n(event, payload);
});
}

View File

@ -0,0 +1,118 @@
/**
* Rocket.Chat EventBus Listener
*
* Subscribes to platform events and posts notifications to RC channels.
* Extends the existing rocketchat-webhook.service with new event coverage.
*
* Channels:
* #shifts shift CRUD + signups
* #canvassing canvass sessions + visit milestones
* #campaigns campaign publish, responses, email milestones
*
* Feature guard: ENABLE_CHAT=true (checked inside rocketchatWebhookService)
*/
import { eventBus } from '../event-bus.service';
import { rocketchatWebhookService } from '../rocketchat-webhook.service';
export function registerRocketChatListener(): void {
// --- Shifts ---
eventBus.subscribe('shift.signup.created', 'rocketchat:shift-signup', (payload) => {
rocketchatWebhookService.onShiftSignup({
userName: payload.userName,
shiftTitle: payload.shiftTitle,
shiftDate: payload.shiftDate,
});
});
eventBus.subscribe('shift.signup.cancelled', 'rocketchat:shift-cancel', (payload) => {
rocketchatWebhookService.onShiftCancellation({
userName: payload.userName,
shiftTitle: payload.shiftTitle,
shiftDate: payload.shiftDate,
});
});
// --- Canvass ---
eventBus.subscribe('canvass.session.completed', 'rocketchat:canvass-completed', (payload) => {
rocketchatWebhookService.onCanvassSessionCompleted({
userName: payload.userName,
visitCount: payload.visitCount,
cutName: payload.cutName,
});
});
// --- Responses ---
eventBus.subscribe('response.submitted', 'rocketchat:response-submitted', (payload) => {
rocketchatWebhookService.onCampaignResponseSubmitted({
campaignTitle: payload.campaignTitle,
representativeName: payload.representativeName,
});
});
// --- Campaigns ---
eventBus.subscribe('campaign.published', 'rocketchat:campaign-published', (payload) => {
rocketchatWebhookService.onCampaignPublished({
campaignTitle: payload.title,
campaignSlug: payload.slug,
});
});
// --- Payments ---
eventBus.subscribe('payment.donation.completed', 'rocketchat:donation', (payload) => {
rocketchatWebhookService.onDonationReceived({
donorName: payload.name || payload.email,
amount: (payload.amountCents / 100).toFixed(2),
});
});
eventBus.subscribe('payment.subscription.activated', 'rocketchat:subscription', (payload) => {
rocketchatWebhookService.onSubscriptionActivated({
userName: payload.name || payload.email,
planName: payload.planName,
});
});
// --- SMS escalations (QUESTION/NEGATIVE responses) ---
eventBus.subscribe('sms.message.received', 'rocketchat:sms-escalation', (payload) => {
if (payload.responseType === 'QUESTION' || payload.responseType === 'NEGATIVE') {
rocketchatWebhookService.onSmsEscalation({
phone: payload.phone,
responseType: payload.responseType,
body: payload.body,
});
}
});
// --- Users ---
eventBus.subscribe('user.approved', 'rocketchat:user-approved', (payload) => {
rocketchatWebhookService.onUserApproved({
userName: payload.name,
role: payload.role,
});
});
// --- Media ---
eventBus.subscribe('media.video.published', 'rocketchat:video-published', (payload) => {
rocketchatWebhookService.onVideoPublished({
videoTitle: payload.title,
});
});
// --- Ticketed Events ---
eventBus.subscribe('ticketed-event.published', 'rocketchat:ticketed-event', (payload) => {
rocketchatWebhookService.onTicketedEventPublished({
eventTitle: payload.title,
eventDate: payload.date,
});
});
}

View File

@ -599,6 +599,51 @@ class GiteaClient {
{ name: tokenName, scopes: ['read', 'write'] as unknown as Record<string, unknown> } as unknown as Record<string, unknown>, { name: tokenName, scopes: ['read', 'write'] as unknown as Record<string, unknown> } as unknown as Record<string, unknown>,
); );
} }
// --- Repository Collaborator Management ---
/**
* Add a collaborator to a repository with the specified permission level.
* @param permission - "read", "write", or "admin"
*/
async addCollaborator(
owner: string,
repo: string,
username: string,
permission: 'read' | 'write' | 'admin' = 'write',
): Promise<void> {
await this.request(
'PUT',
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/collaborators/${encodeURIComponent(username)}`,
{ permission },
);
}
/**
* Remove a collaborator from a repository.
*/
async removeCollaborator(owner: string, repo: string, username: string): Promise<void> {
await this.request(
'DELETE',
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/collaborators/${encodeURIComponent(username)}`,
);
}
/**
* Check if a user is a collaborator on a repository.
* Returns true if they are, false otherwise.
*/
async isCollaborator(owner: string, repo: string, username: string): Promise<boolean> {
try {
await this.request(
'GET',
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/collaborators/${encodeURIComponent(username)}`,
);
return true;
} catch {
return false;
}
}
} }
export const giteaClient = new GiteaClient(); export const giteaClient = new GiteaClient();

View File

@ -0,0 +1,129 @@
import { Queue, Worker, type Job } from 'bullmq';
import { env } from '../config/env';
import { prisma } from '../config/database';
import { logger } from '../utils/logger';
interface PollAutoCloseJobData {
pollId: string;
}
class PollAutoCloseQueueService {
private queue: Queue;
private worker: Worker | null = null;
constructor() {
this.queue = new Queue('straw-poll-auto-close', {
connection: { url: env.REDIS_URL },
defaultJobOptions: {
attempts: 3,
backoff: { type: 'exponential', delay: 5000 },
removeOnComplete: { age: 7 * 24 * 60 * 60, count: 500 },
removeOnFail: { age: 30 * 24 * 60 * 60 },
},
});
}
startWorker() {
this.worker = new Worker(
'straw-poll-auto-close',
async (job: Job<PollAutoCloseJobData>) => {
const { pollId } = job.data;
logger.info(`Processing straw poll auto-close job ${job.id}`, { pollId });
// Dynamic import to avoid circular dependency
const { strawPollsService } = await import(
'../modules/polls/polls.service'
);
await strawPollsService.closePoll(pollId);
},
{
connection: { url: env.REDIS_URL },
concurrency: 1,
}
);
this.worker.on('completed', (job) => {
logger.info(`Straw poll auto-close job ${job.id} completed`);
});
this.worker.on('failed', (job, err) => {
logger.error(`Straw poll auto-close job ${job?.id} failed: ${err.message}`);
});
logger.info('Straw poll auto-close queue worker started');
this.recoverOnStartup().catch((err) =>
logger.error('Straw poll auto-close startup recovery failed', { error: err })
);
}
async scheduleJob(pollId: string, deadline: Date): Promise<string | null> {
const delay = deadline.getTime() - Date.now();
if (delay <= 0) {
const job = await this.queue.add(`close-${pollId}`, { pollId }, {
jobId: `poll-close-${pollId}`,
});
return job.id ?? null;
}
const job = await this.queue.add(`close-${pollId}`, { pollId }, {
delay,
jobId: `poll-close-${pollId}`,
});
logger.info(`Scheduled straw poll auto-close for ${deadline.toISOString()}`, {
pollId,
jobId: job.id,
delayMs: delay,
});
return job.id ?? null;
}
async cancelJob(pollId: string): Promise<void> {
try {
const jobs = await this.queue.getJobs(['delayed', 'waiting']);
for (const job of jobs) {
if (job.data.pollId === pollId) {
await job.remove();
logger.info(`Cancelled auto-close job for straw poll ${pollId}`, { jobId: job.id });
}
}
} catch (error) {
logger.error('Failed to cancel straw poll auto-close job', { error, pollId });
}
}
private async recoverOnStartup() {
const activePolls = await prisma.strawPoll.findMany({
where: {
status: 'ACTIVE',
closesAt: { not: null },
},
select: { id: true, closesAt: true },
});
for (const poll of activePolls) {
if (!poll.closesAt) continue;
const jobId = await this.scheduleJob(poll.id, poll.closesAt);
if (jobId) {
await prisma.strawPoll.update({
where: { id: poll.id },
data: { autoCloseJobId: jobId },
}).catch(() => {});
}
}
if (activePolls.length > 0) {
logger.info(`Recovered ${activePolls.length} straw poll auto-close jobs on startup`);
}
}
async close() {
if (this.worker) {
await this.worker.close();
}
await this.queue.close();
logger.info('Straw poll auto-close queue closed');
}
}
export const pollAutoCloseQueueService = new PollAutoCloseQueueService();

View File

@ -4,7 +4,7 @@ import { env } from '../config/env';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { siteSettingsService } from '../modules/settings/settings.service'; import { siteSettingsService } from '../modules/settings/settings.service';
import { notificationQueueService } from './notification-queue.service'; import { notificationQueueService } from './notification-queue.service';
import { listmonkEventSyncService } from './listmonk-event-sync.service'; import { eventBus } from './event-bus.service';
/** /**
* Volunteer Re-Engagement Scanner * Volunteer Re-Engagement Scanner
@ -76,11 +76,11 @@ class ReengagementService {
const cooldownSeconds = cooldownDays * 24 * 60 * 60; const cooldownSeconds = cooldownDays * 24 * 60 * 60;
await redis.set(cooldownKey, '', 'EX', cooldownSeconds); await redis.set(cooldownKey, '', 'EX', cooldownSeconds);
// Listmonk event sync: tag as re-engaged // Publish re-engagement event
listmonkEventSyncService.onReengagementSent({ eventBus.publish('reengagement.sent', {
email: volunteer.email, email: volunteer.email,
name: volunteer.name || volunteer.email, name: volunteer.name || volunteer.email,
}).catch(() => {}); });
sent++; sent++;
} catch (err) { } catch (err) {

View File

@ -58,6 +58,63 @@ class RocketChatWebhookService {
await this.notify('#campaigns', text, '#9b59b6'); await this.notify('#campaigns', text, '#9b59b6');
} }
async onCampaignPublished(data: {
campaignTitle: string;
campaignSlug: string;
}): Promise<void> {
const text = `:newspaper: Campaign published: *${data.campaignTitle}* → /campaigns/${data.campaignSlug}`;
await this.notify('#campaigns', text, '#27ae60');
}
async onDonationReceived(data: {
donorName: string;
amount: string;
}): Promise<void> {
const text = `:money_with_wings: **${data.donorName}** donated **$${data.amount}**`;
await this.notify('#campaigns', text, '#f1c40f');
}
async onSubscriptionActivated(data: {
userName: string;
planName: string;
}): Promise<void> {
const text = `:star: **${data.userName}** subscribed to *${data.planName}*`;
await this.notify('#campaigns', text, '#9b59b6');
}
async onSmsEscalation(data: {
phone: string;
responseType: string;
body: string;
}): Promise<void> {
const preview = data.body.length > 100 ? data.body.slice(0, 100) + '...' : data.body;
const text = `:warning: SMS ${data.responseType} from ${data.phone}: "${preview}"`;
await this.notify('#campaigns', text, '#e74c3c');
}
async onUserApproved(data: {
userName: string;
role: string;
}): Promise<void> {
const text = `:white_check_mark: User approved: **${data.userName}** (${data.role})`;
await this.notify('#campaigns', text, '#2ecc71');
}
async onVideoPublished(data: {
videoTitle: string;
}): Promise<void> {
const text = `:film_projector: New video published: *${data.videoTitle}*`;
await this.notify('#campaigns', text, '#3498db');
}
async onTicketedEventPublished(data: {
eventTitle: string;
eventDate: string;
}): Promise<void> {
const text = `:ticket: Event published: *${data.eventTitle}* (${data.eventDate})`;
await this.notify('#campaigns', text, '#e67e22');
}
/** /**
* Ensure default notification channels exist in Rocket.Chat. * Ensure default notification channels exist in Rocket.Chat.
* Called during service startup. * Called during service startup.

View File

@ -4,6 +4,7 @@ import { env } from '../config/env';
import { prisma } from '../config/database'; import { prisma } from '../config/database';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { termuxClient } from './termux.client'; import { termuxClient } from './termux.client';
import { eventBus } from './event-bus.service';
export interface SmsJobData { export interface SmsJobData {
recipientId: string; // empty string for notification jobs recipientId: string; // empty string for notification jobs
@ -129,6 +130,13 @@ class SmsQueueService {
where: { id: campaignId }, where: { id: campaignId },
data: { totalSent: { increment: 1 } }, data: { totalSent: { increment: 1 } },
}); });
eventBus.publish('sms.message.sent', {
messageId: smsMessage.id,
campaignId,
phone,
body: message,
});
} else { } else {
await prisma.smsCampaign.update({ await prisma.smsCampaign.update({
where: { id: campaignId }, where: { id: campaignId },
@ -136,6 +144,23 @@ class SmsQueueService {
}); });
throw new Error(`Failed to send SMS to ${phone}: ${result.error}`); throw new Error(`Failed to send SMS to ${phone}: ${result.error}`);
} }
// Check if campaign is complete (no more PENDING recipients)
const pendingCount = await prisma.smsCampaignRecipient.count({
where: { campaignId, status: 'PENDING' },
});
if (pendingCount === 0) {
const updatedCampaign = await prisma.smsCampaign.update({
where: { id: campaignId },
data: { status: 'COMPLETED', completedAt: new Date() },
});
eventBus.publish('sms.campaign.completed', {
campaignId,
title: updatedCampaign.name,
sentCount: updatedCampaign.totalSent,
failedCount: updatedCampaign.totalFailed,
});
}
} else { } else {
// Notification job: just throw on failure for BullMQ retry // Notification job: just throw on failure for BullMQ retry
if (!result.success) { if (!result.success) {

View File

@ -2,6 +2,7 @@ import { env } from '../config/env';
import { prisma } from '../config/database'; import { prisma } from '../config/database';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { termuxClient } from './termux.client'; import { termuxClient } from './termux.client';
import { eventBus } from './event-bus.service';
import type { SmsResponseType } from '@prisma/client'; import type { SmsResponseType } from '@prisma/client';
// Opt-out keywords (case-insensitive) // Opt-out keywords (case-insensitive)
@ -116,6 +117,14 @@ class SmsResponseSyncService {
}, },
}); });
eventBus.publish('sms.message.received', {
messageId: smsMessage.id,
conversationId: conversation?.id || '',
phone: msg.number,
body: msg.body,
responseType,
});
// Update conversation stats if we have one // Update conversation stats if we have one
if (conversation) { if (conversation) {
const updates: Record<string, unknown> = { const updates: Record<string, unknown> = {

View File

@ -19,7 +19,7 @@ export async function getStripe(): Promise<Stripe> {
throw new Error('Stripe secret key not configured — set it in admin payment settings'); throw new Error('Stripe secret key not configured — set it in admin payment settings');
} }
_stripe = new Stripe(secretKey); _stripe = new Stripe(secretKey, { apiVersion: '2026-01-28.clover' });
logger.info('Stripe client initialized'); logger.info('Stripe client initialized');
return _stripe; return _stripe;

View File

@ -1,9 +1,10 @@
import { createHmac } from 'crypto'; import { createHmac } from 'crypto';
import { Prisma } from '@prisma/client'; import { Prisma, UserRole } from '@prisma/client';
import { prisma } from '../../config/database'; import { prisma } from '../../config/database';
import { env } from '../../config/env'; import { env } from '../../config/env';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
import { giteaClient } from '../gitea.client'; import { giteaClient } from '../gitea.client';
import { CONTENT_ROLES } from '../../utils/roles';
import type { ServiceProvisioner, ProvisionerConfig, ProvisionResult, CMUser } from './provisioner.interface'; import type { ServiceProvisioner, ProvisionerConfig, ProvisionResult, CMUser } from './provisioner.interface';
const ROLE_MAP: Record<string, string[]> = { const ROLE_MAP: Record<string, string[]> = {
@ -14,9 +15,13 @@ const ROLE_MAP: Record<string, string[]> = {
TEMP: [], TEMP: [],
}; };
/** The private docs repo name created by gitea-setup */
const DOCS_REPO_NAME = 'changemaker.lite';
/** Deterministic password — never exposed to users */ /** Deterministic password — never exposed to users */
function generateGiteaPassword(userId: string): string { function generateGiteaPassword(userId: string): string {
return createHmac('sha256', env.JWT_ACCESS_SECRET) const salt = env.SERVICE_PASSWORD_SALT || env.JWT_ACCESS_SECRET;
return createHmac('sha256', salt)
.update(`gitea:${userId}`) .update(`gitea:${userId}`)
.digest('hex'); .digest('hex');
} }
@ -94,6 +99,9 @@ class GiteaProvisioner implements ServiceProvisioner {
}, },
}); });
// Grant docs repo access based on role
await this.syncDocsRepoAccess(giteaUser.login, user.role);
return { return {
success: true, success: true,
serviceUserId: String(giteaUser.id), serviceUserId: String(giteaUser.id),
@ -121,6 +129,9 @@ class GiteaProvisioner implements ServiceProvisioner {
admin: isAdmin, admin: isAdmin,
active: user.status === 'ACTIVE', active: user.status === 'ACTIVE',
}); });
// Re-evaluate docs repo access based on current role
await this.syncDocsRepoAccess(username, user.role);
} }
async deactivate(serviceUserId: string): Promise<void> { async deactivate(serviceUserId: string): Promise<void> {
@ -140,13 +151,57 @@ class GiteaProvisioner implements ServiceProvisioner {
} }
await giteaClient.adminUpdateUser(username, { active: false }); await giteaClient.adminUpdateUser(username, { active: false });
// Remove docs repo collaborator access
try {
const config = await giteaClient.getConfig();
if (config.repoOwner) {
await giteaClient.removeCollaborator(config.repoOwner, DOCS_REPO_NAME, username);
}
} catch {
// Ignore — user may not have been a collaborator
}
logger.info(`Gitea provisioner: deactivated user ${username}`); logger.info(`Gitea provisioner: deactivated user ${username}`);
} }
async getAuthToken(_user: CMUser, _serviceUserId: string): Promise<string | null> { async getAuthToken(_user: CMUser, _serviceUserId: string): Promise<string | null> {
// Gitea SSO via API tokens could be implemented here if needed for iframe embedding
return null; return null;
} }
/**
* Ensure the user's collaborator status on the private docs repo matches
* their role. CONTENT_ROLES (SUPER_ADMIN, CONTENT_ADMIN) get write access;
* SUPER_ADMIN users already have admin access via the Gitea admin flag,
* but we also add them as explicit collaborators for consistency.
* Users without CONTENT_ROLES are removed as collaborators.
*/
private async syncDocsRepoAccess(username: string, role: string): Promise<void> {
try {
const config = await giteaClient.getConfig();
const repoOwner = config.repoOwner;
if (!repoOwner) return; // Setup not complete — no repo owner yet
const hasDocsAccess = CONTENT_ROLES.includes(role as UserRole);
if (hasDocsAccess) {
// SUPER_ADMIN → admin, CONTENT_ADMIN → write
const permission = role === 'SUPER_ADMIN' ? 'admin' : 'write';
await giteaClient.addCollaborator(repoOwner, DOCS_REPO_NAME, username, permission);
logger.debug(`Gitea provisioner: granted ${permission} access to ${repoOwner}/${DOCS_REPO_NAME} for ${username}`);
} else {
// Remove access if user no longer has CONTENT_ROLES
const isCollab = await giteaClient.isCollaborator(repoOwner, DOCS_REPO_NAME, username);
if (isCollab) {
await giteaClient.removeCollaborator(repoOwner, DOCS_REPO_NAME, username);
logger.debug(`Gitea provisioner: removed ${username} from ${repoOwner}/${DOCS_REPO_NAME}`);
}
}
} catch (err) {
// Non-fatal — don't block provisioning if repo access sync fails
logger.warn(`Gitea provisioner: docs repo access sync failed for ${username}:`, err instanceof Error ? err.message : err);
}
}
} }
export const giteaProvisioner = new GiteaProvisioner(); export const giteaProvisioner = new GiteaProvisioner();

View File

@ -16,7 +16,8 @@ const ROLE_MAP: Record<string, string[]> = {
/** Deterministic password — never exposed to users, only used for RC internal auth */ /** Deterministic password — never exposed to users, only used for RC internal auth */
function generateRCPassword(userId: string): string { function generateRCPassword(userId: string): string {
return createHmac('sha256', env.JWT_ACCESS_SECRET) const salt = env.SERVICE_PASSWORD_SALT || env.JWT_ACCESS_SECRET;
return createHmac('sha256', salt)
.update(`rc:${userId}`) .update(`rc:${userId}`)
.digest('hex'); .digest('hex');
} }

504
api/src/types/events.ts Normal file
View File

@ -0,0 +1,504 @@
/**
* Platform Event Catalog
*
* Typed event definitions for the EventBus. Each event has a dot-separated name
* and a strongly-typed payload. Services publish events; listeners subscribe.
*
* Naming convention: <module>.<entity>.<action>
* e.g. shift.signup.created, campaign.email.sent, payment.donation.completed
*/
// =============================================================================
// SHIFT EVENTS
// =============================================================================
export interface ShiftCreatedEvent {
shiftId: string;
title: string;
date: string;
startTime: string;
endTime: string;
cutId?: string | null;
cutName?: string | null;
createdByUserId: string;
}
export interface ShiftUpdatedEvent {
shiftId: string;
title: string;
date: string;
startTime: string;
endTime: string;
cutId?: string | null;
cutName?: string | null;
changes: string[]; // field names that changed
}
export interface ShiftDeletedEvent {
shiftId: string;
title: string;
date: string;
}
export interface ShiftSignupCreatedEvent {
shiftId: string;
shiftTitle: string;
shiftDate: string;
userName: string;
userEmail: string;
userId?: string | null;
cutName?: string | null;
signupType: 'admin' | 'volunteer' | 'public';
}
export interface ShiftSignupCancelledEvent {
shiftId: string;
shiftTitle: string;
shiftDate: string;
userName: string;
userEmail: string;
signupType: 'admin' | 'volunteer' | 'public';
}
// =============================================================================
// CAMPAIGN EVENTS (Influence)
// =============================================================================
export interface CampaignCreatedEvent {
campaignId: string;
title: string;
slug: string;
createdByUserId: string;
}
export interface CampaignUpdatedEvent {
campaignId: string;
title: string;
slug: string;
changes: string[];
}
export interface CampaignDeletedEvent {
campaignId: string;
title: string;
slug: string;
}
export interface CampaignPublishedEvent {
campaignId: string;
title: string;
slug: string;
}
export interface CampaignStatusChangedEvent {
campaignId: string;
title: string;
slug: string;
oldStatus: string;
newStatus: string;
}
export interface CampaignEmailSentEvent {
email: string;
name: string;
campaignSlug: string;
postalCode?: string;
}
// =============================================================================
// RESPONSE EVENTS (Influence)
// =============================================================================
export interface ResponseSubmittedEvent {
responseId: string;
campaignId: string;
campaignTitle: string;
representativeName: string;
userEmail?: string;
}
export interface ResponseApprovedEvent {
responseId: string;
campaignId: string;
campaignTitle: string;
}
export interface ResponseRejectedEvent {
responseId: string;
campaignId: string;
campaignTitle: string;
reason?: string;
}
// =============================================================================
// CANVASS EVENTS
// =============================================================================
export interface CanvassSessionStartedEvent {
sessionId: string;
userId: string;
userName: string;
cutId: string;
cutName: string;
}
export interface CanvassSessionCompletedEvent {
sessionId: string;
userId: string;
userName: string;
userEmail: string;
cutName: string;
visitCount: number;
outcomes: Record<string, number>;
}
export interface CanvassVisitRecordedEvent {
visitId: string;
sessionId: string;
addressId: string;
outcome: string;
email?: string | null;
name?: string | null;
supportLevel?: string | null;
sign?: boolean;
address?: string | null;
}
// =============================================================================
// USER EVENTS
// =============================================================================
export interface UserCreatedEvent {
userId: string;
email: string;
name: string;
role: string;
}
export interface UserUpdatedEvent {
userId: string;
email: string;
name: string;
role: string;
changes: string[];
}
export interface UserApprovedEvent {
userId: string;
email: string;
name: string;
role: string;
approvedByUserId: string;
}
export interface UserDeletedEvent {
userId: string;
email: string;
name: string;
}
// =============================================================================
// PAYMENT EVENTS
// =============================================================================
export interface SubscriptionActivatedEvent {
email: string;
name: string;
planName: string;
subscriptionId: string;
amountCents?: number;
}
export interface SubscriptionCancelledEvent {
email: string;
name: string;
subscriptionId: string;
}
export interface DonationCompletedEvent {
email: string;
name: string;
amountCents: number;
orderId: string;
donationPageSlug?: string;
}
export interface DonationRefundedEvent {
email: string;
orderId: string;
amountCents: number;
}
export interface ProductPurchasedEvent {
email: string;
name: string;
productTitle: string;
amountCents: number;
orderId: string;
}
// =============================================================================
// SMS EVENTS
// =============================================================================
export interface SmsCampaignStartedEvent {
campaignId: string;
title: string;
recipientCount: number;
}
export interface SmsCampaignCompletedEvent {
campaignId: string;
title: string;
sentCount: number;
failedCount: number;
}
export interface SmsMessageSentEvent {
messageId: string;
campaignId?: string;
phone: string;
body: string;
}
export interface SmsMessageReceivedEvent {
messageId: string;
conversationId: string;
phone: string;
body: string;
responseType?: string; // POSITIVE, NEGATIVE, QUESTION, OPT_OUT, NEUTRAL
}
// =============================================================================
// MEDIA EVENTS
// =============================================================================
export interface VideoPublishedEvent {
videoId: string;
title: string;
publishedByUserId: string;
}
export interface VideoUnpublishedEvent {
videoId: string;
title: string;
}
export interface VideoViewedEvent {
videoId: string;
videoTitle: string;
userId?: string | null;
sessionId: string;
}
// =============================================================================
// TICKETED EVENT EVENTS
// =============================================================================
export interface TicketedEventPublishedEvent {
eventId: string;
title: string;
date: string;
startTime: string;
endTime?: string;
location?: string;
gancioEventId?: number | null;
}
export interface TicketedEventCancelledEvent {
eventId: string;
title: string;
}
// =============================================================================
// MEETING EVENTS
// =============================================================================
export interface MeetingCreatedEvent {
meetingId: string;
title: string;
scheduledAt: string;
jitsiRoomName?: string;
createdByUserId: string;
}
export interface MeetingUpdatedEvent {
meetingId: string;
title: string;
scheduledAt: string;
changes: string[];
}
export interface MeetingDeletedEvent {
meetingId: string;
title: string;
}
// =============================================================================
// SOCIAL EVENTS
// =============================================================================
export interface ImpactStoryPublishedEvent {
storyId: string;
title: string;
authorUserId: string;
campaignId?: string | null;
}
// =============================================================================
// CONTACT / CRM EVENTS
// =============================================================================
export interface ContactTagsChangedEvent {
email: string;
name: string;
contactId: string;
addedTags: string[];
removedTags: string[];
}
export interface ContactCreatedEvent {
contactId: string;
email?: string;
name: string;
}
export interface ContactMergedEvent {
survivorId: string;
mergedId: string;
survivorEmail?: string;
}
export interface AddressUpdatedEvent {
email: string;
name: string;
supportLevel?: string | null;
sign?: boolean;
address?: string | null;
}
// =============================================================================
// REENGAGEMENT EVENTS
// =============================================================================
export interface ReengagementSentEvent {
email: string;
name: string;
}
// =============================================================================
// LISTMONK WEBHOOK EVENTS (inbound from Listmonk)
// =============================================================================
export interface ListmonkEmailOpenedEvent {
subscriberEmail: string;
campaignId: number;
campaignName: string;
}
export interface ListmonkEmailClickedEvent {
subscriberEmail: string;
campaignId: number;
campaignName: string;
url: string;
}
export interface ListmonkEmailBouncedEvent {
subscriberEmail: string;
campaignId: number;
bounceType: string;
}
export interface ListmonkUnsubscribedEvent {
subscriberEmail: string;
listId: number;
listName: string;
}
// =============================================================================
// EVENT MAP — maps event names to payload types
// =============================================================================
export interface PlatformEventMap {
// Shifts
'shift.created': ShiftCreatedEvent;
'shift.updated': ShiftUpdatedEvent;
'shift.deleted': ShiftDeletedEvent;
'shift.signup.created': ShiftSignupCreatedEvent;
'shift.signup.cancelled': ShiftSignupCancelledEvent;
// Campaigns
'campaign.created': CampaignCreatedEvent;
'campaign.updated': CampaignUpdatedEvent;
'campaign.deleted': CampaignDeletedEvent;
'campaign.published': CampaignPublishedEvent;
'campaign.status.changed': CampaignStatusChangedEvent;
'campaign.email.sent': CampaignEmailSentEvent;
// Responses
'response.submitted': ResponseSubmittedEvent;
'response.approved': ResponseApprovedEvent;
'response.rejected': ResponseRejectedEvent;
// Canvass
'canvass.session.started': CanvassSessionStartedEvent;
'canvass.session.completed': CanvassSessionCompletedEvent;
'canvass.visit.recorded': CanvassVisitRecordedEvent;
// Users
'user.created': UserCreatedEvent;
'user.updated': UserUpdatedEvent;
'user.approved': UserApprovedEvent;
'user.deleted': UserDeletedEvent;
// Payments
'payment.subscription.activated': SubscriptionActivatedEvent;
'payment.subscription.cancelled': SubscriptionCancelledEvent;
'payment.donation.completed': DonationCompletedEvent;
'payment.donation.refunded': DonationRefundedEvent;
'payment.product.purchased': ProductPurchasedEvent;
// SMS
'sms.campaign.started': SmsCampaignStartedEvent;
'sms.campaign.completed': SmsCampaignCompletedEvent;
'sms.message.sent': SmsMessageSentEvent;
'sms.message.received': SmsMessageReceivedEvent;
// Media
'media.video.published': VideoPublishedEvent;
'media.video.unpublished': VideoUnpublishedEvent;
'media.video.viewed': VideoViewedEvent;
// Ticketed Events
'ticketed-event.published': TicketedEventPublishedEvent;
'ticketed-event.cancelled': TicketedEventCancelledEvent;
// Meetings
'meeting.created': MeetingCreatedEvent;
'meeting.updated': MeetingUpdatedEvent;
'meeting.deleted': MeetingDeletedEvent;
// Social
'social.impact-story.published': ImpactStoryPublishedEvent;
// Contact / CRM
'contact.created': ContactCreatedEvent;
'contact.merged': ContactMergedEvent;
'contact.tags.changed': ContactTagsChangedEvent;
'contact.address.updated': AddressUpdatedEvent;
// Reengagement
'reengagement.sent': ReengagementSentEvent;
// Listmonk webhooks (inbound)
'listmonk.email.opened': ListmonkEmailOpenedEvent;
'listmonk.email.clicked': ListmonkEmailClickedEvent;
'listmonk.email.bounced': ListmonkEmailBouncedEvent;
'listmonk.unsubscribed': ListmonkUnsubscribedEvent;
}
/** All valid platform event names */
export type PlatformEventName = keyof PlatformEventMap;
/** Helper: extract payload type for a given event name */
export type EventPayload<E extends PlatformEventName> = PlatformEventMap[E];

View File

@ -10,6 +10,7 @@ const ROLE_PRIORITY: Record<string, number> = {
PAYMENTS_ADMIN: 4, PAYMENTS_ADMIN: 4,
EVENTS_ADMIN: 4, EVENTS_ADMIN: 4,
SOCIAL_ADMIN: 4, SOCIAL_ADMIN: 4,
POLLS_ADMIN: 4,
USER: 2, USER: 2,
TEMP: 1, TEMP: 1,
}; };
@ -25,6 +26,7 @@ export const ADMIN_ROLES: UserRole[] = [
UserRole.PAYMENTS_ADMIN, UserRole.PAYMENTS_ADMIN,
UserRole.EVENTS_ADMIN, UserRole.EVENTS_ADMIN,
UserRole.SOCIAL_ADMIN, UserRole.SOCIAL_ADMIN,
UserRole.POLLS_ADMIN,
]; ];
// Module-specific role groups // Module-specific role groups
@ -38,6 +40,7 @@ export const EVENTS_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.EVENTS_A
export const SOCIAL_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.SOCIAL_ADMIN]; export const SOCIAL_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.SOCIAL_ADMIN];
export const SYSTEM_ROLES: UserRole[] = [UserRole.SUPER_ADMIN]; export const SYSTEM_ROLES: UserRole[] = [UserRole.SUPER_ADMIN];
export const SCHEDULING_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN, UserRole.EVENTS_ADMIN]; export const SCHEDULING_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN, UserRole.EVENTS_ADMIN];
export const POLLS_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.POLLS_ADMIN, UserRole.INFLUENCE_ADMIN];
/** Check if the user has any of the specified roles */ /** Check if the user has any of the specified roles */
export function hasAnyRole(user: { roles?: unknown; role?: UserRole }, roles: UserRole[]): boolean { export function hasAnyRole(user: { roles?: unknown; role?: UserRole }, roles: UserRole[]): boolean {

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