Compare commits
7 Commits
3de1d3fca5
...
91db29402c
| Author | SHA1 | Date | |
|---|---|---|---|
| 91db29402c | |||
| 9321aeb263 | |||
| 5d15b4cffa | |||
| 902adce646 | |||
| 68434c51a6 | |||
| 075a7c8c4a | |||
| 0c2ffe754e |
@ -53,6 +53,14 @@ JWT_REFRESH_EXPIRY=7d
|
||||
# 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) ---
|
||||
# These credentials are used to create the initial super admin account
|
||||
# Change these before running the seed script in production
|
||||
|
||||
@ -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`.
|
||||
|
||||
**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:**
|
||||
- ✅ Phases 1-14 Complete (Foundation through Monitoring + DevOps)
|
||||
@ -160,7 +160,7 @@ changemaker.lite/
|
||||
The fastest way to deploy. No source code, no compilation:
|
||||
|
||||
```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:
|
||||
@ -173,11 +173,10 @@ Pre-built images are pulled from `gitea.bnkops.com/admin` (~2 min). Database mig
|
||||
|
||||
### Source Install (Development)
|
||||
|
||||
1. **Clone repository and checkout v2 branch:**
|
||||
1. **Clone repository:**
|
||||
```bash
|
||||
git clone <repo-url> changemaker.lite
|
||||
cd changemaker.lite
|
||||
git checkout v2
|
||||
```
|
||||
|
||||
2. **Create environment file:**
|
||||
@ -321,7 +320,7 @@ docker compose down
|
||||
./scripts/upgrade.sh --use-registry --force --skip-backup
|
||||
|
||||
# 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:**
|
||||
|
||||
@ -174,7 +174,7 @@ The tarball contains:
|
||||
|
||||
```bash
|
||||
# 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
|
||||
curl -LO https://gitea.bnkops.com/admin/changemaker.lite/releases/latest/download/changemaker-lite-latest.tar.gz
|
||||
|
||||
@ -105,7 +105,7 @@ Send SMS campaigns via an Android bridge, sync subscribers to Listmonk for newsl
|
||||
|
||||
```bash
|
||||
# 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
|
||||
docker compose up -d
|
||||
@ -115,7 +115,7 @@ Or clone and build from source:
|
||||
|
||||
```bash
|
||||
git clone <repo-url> changemaker.lite
|
||||
cd changemaker.lite && git checkout v2
|
||||
cd changemaker.lite
|
||||
|
||||
cp .env.example .env
|
||||
# Edit .env -- set passwords, JWT secrets, admin credentials
|
||||
|
||||
161
SERVICE_INTEGRATIONS.md
Normal file
161
SERVICE_INTEGRATIONS.md
Normal 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
225
admin/package-lock.json
generated
@ -1154,9 +1154,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
|
||||
"integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
|
||||
"integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@ -1167,9 +1167,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz",
|
||||
"integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@ -1180,9 +1180,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz",
|
||||
"integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@ -1193,9 +1193,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
|
||||
"integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz",
|
||||
"integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@ -1206,9 +1206,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz",
|
||||
"integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@ -1219,9 +1219,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
|
||||
"integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz",
|
||||
"integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@ -1232,9 +1232,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
|
||||
"integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz",
|
||||
"integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@ -1245,9 +1245,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
|
||||
"integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz",
|
||||
"integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@ -1258,9 +1258,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz",
|
||||
"integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@ -1271,9 +1271,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz",
|
||||
"integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@ -1284,9 +1284,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz",
|
||||
"integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@ -1297,9 +1297,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz",
|
||||
"integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@ -1310,9 +1310,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz",
|
||||
"integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@ -1323,9 +1323,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz",
|
||||
"integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@ -1336,9 +1336,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz",
|
||||
"integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@ -1349,9 +1349,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz",
|
||||
"integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@ -1362,9 +1362,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz",
|
||||
"integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@ -1375,9 +1375,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz",
|
||||
"integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@ -1388,9 +1388,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
|
||||
"integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz",
|
||||
"integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@ -1401,9 +1401,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
|
||||
"integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz",
|
||||
"integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@ -1414,9 +1414,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
|
||||
"integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz",
|
||||
"integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@ -1427,9 +1427,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
|
||||
"integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz",
|
||||
"integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@ -1440,9 +1440,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
|
||||
"integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz",
|
||||
"integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@ -1453,9 +1453,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
|
||||
"integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz",
|
||||
"integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@ -1466,9 +1466,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
|
||||
"integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz",
|
||||
"integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@ -2261,9 +2261,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
|
||||
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
|
||||
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
@ -2860,9 +2860,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@ -3651,9 +3651,9 @@
|
||||
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.57.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
|
||||
"integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
|
||||
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
@ -3666,31 +3666,31 @@
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.57.1",
|
||||
"@rollup/rollup-android-arm64": "4.57.1",
|
||||
"@rollup/rollup-darwin-arm64": "4.57.1",
|
||||
"@rollup/rollup-darwin-x64": "4.57.1",
|
||||
"@rollup/rollup-freebsd-arm64": "4.57.1",
|
||||
"@rollup/rollup-freebsd-x64": "4.57.1",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.57.1",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.57.1",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.57.1",
|
||||
"@rollup/rollup-linux-x64-musl": "4.57.1",
|
||||
"@rollup/rollup-openbsd-x64": "4.57.1",
|
||||
"@rollup/rollup-openharmony-arm64": "4.57.1",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.57.1",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.57.1",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.57.1",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.57.1",
|
||||
"@rollup/rollup-android-arm-eabi": "4.60.1",
|
||||
"@rollup/rollup-android-arm64": "4.60.1",
|
||||
"@rollup/rollup-darwin-arm64": "4.60.1",
|
||||
"@rollup/rollup-darwin-x64": "4.60.1",
|
||||
"@rollup/rollup-freebsd-arm64": "4.60.1",
|
||||
"@rollup/rollup-freebsd-x64": "4.60.1",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.60.1",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.60.1",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.60.1",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.60.1",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.60.1",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.60.1",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.60.1",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.60.1",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.60.1",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.60.1",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.60.1",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.60.1",
|
||||
"@rollup/rollup-linux-x64-musl": "4.60.1",
|
||||
"@rollup/rollup-openbsd-x64": "4.60.1",
|
||||
"@rollup/rollup-openharmony-arm64": "4.60.1",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.60.1",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.60.1",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.60.1",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.60.1",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
@ -3993,10 +3993,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||
"license": "ISC",
|
||||
"version": "2.8.3",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
||||
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
|
||||
@ -112,6 +112,7 @@ import {
|
||||
EVENTS_ROLES,
|
||||
SOCIAL_ROLES,
|
||||
SYSTEM_ROLES,
|
||||
POLLS_ROLES,
|
||||
} from '@/types/api';
|
||||
import { isAdmin } from '@/utils/roles';
|
||||
import QuickJoinPage from '@/pages/public/QuickJoinPage';
|
||||
@ -132,6 +133,7 @@ import ReferralAdminPage from '@/pages/social/ReferralAdminPage';
|
||||
import SpotlightAdminPage from '@/pages/social/SpotlightAdminPage';
|
||||
import ChallengesAdminPage from '@/pages/social/ChallengesAdminPage';
|
||||
import ImpactStoriesPage from '@/pages/influence/ImpactStoriesPage';
|
||||
import StrawPollsPage from '@/pages/influence/StrawPollsPage';
|
||||
import ReferralsPage from '@/pages/volunteer/ReferralsPage';
|
||||
import ChallengesPage from '@/pages/volunteer/ChallengesPage';
|
||||
import ChallengeDetailPage from '@/pages/volunteer/ChallengeDetailPage';
|
||||
@ -142,6 +144,8 @@ import MeetingAgendaPage from '@/pages/MeetingAgendaPage';
|
||||
import ActionItemsPage from '@/pages/ActionItemsPage';
|
||||
import SchedulingPollPage from '@/pages/public/SchedulingPollPage';
|
||||
import PollsListPage from '@/pages/public/PollsListPage';
|
||||
import StrawPollPage from '@/pages/public/StrawPollPage';
|
||||
import StrawPollsListPage from '@/pages/public/StrawPollsListPage';
|
||||
import JitsiAuthPage from '@/pages/JitsiAuthPage';
|
||||
import SchedulingCalendarPage from '@/pages/SchedulingCalendarPage';
|
||||
import AdminCalendarViewPage from '@/pages/AdminCalendarViewPage';
|
||||
@ -276,6 +280,14 @@ export default function App() {
|
||||
<Route index element={<SchedulingPollPage />} />
|
||||
</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 */}
|
||||
<Route path="/event/:slug" element={<FeatureGate feature="enableTicketedEvents"><PublicLayout /></FeatureGate>}>
|
||||
<Route index element={<TicketedEventDetailPage />} />
|
||||
@ -562,6 +574,14 @@ export default function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="influence/straw-polls"
|
||||
element={
|
||||
<ProtectedRoute requiredRoles={POLLS_ROLES}>
|
||||
<StrawPollsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="listmonk"
|
||||
element={
|
||||
|
||||
@ -71,6 +71,7 @@ import {
|
||||
MEDIA_ROLES,
|
||||
PAYMENTS_ROLES,
|
||||
SOCIAL_ROLES,
|
||||
POLLS_ROLES,
|
||||
} from '@/types/api';
|
||||
import { buildHomeUrl, resolveNavUrl } from '@/lib/service-url';
|
||||
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/influence/effectiveness', icon: <LineChartOutlined />, label: 'Effectiveness' },
|
||||
{ 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>
|
||||
</Header>
|
||||
<Content
|
||||
id="app-content-area"
|
||||
style={{
|
||||
margin: fullBleed ? 0 : (isMobile ? 12 : 24),
|
||||
padding: fullBleed ? 0 : (isMobile ? 16 : 24),
|
||||
@ -719,6 +722,7 @@ export default function AppLayout() {
|
||||
borderRadius: fullBleed ? 0 : token.borderRadiusLG,
|
||||
minHeight: 280,
|
||||
overflow: fullBleed ? 'hidden' : undefined,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Outlet context={{ setPageHeader } satisfies AppOutletContext} />
|
||||
|
||||
@ -22,10 +22,11 @@ const FEATURE_LABELS: Record<string, string> = {
|
||||
enableMeetingPlanner: 'Meeting Planner',
|
||||
enableTicketedEvents: 'Ticketed Events',
|
||||
enableSocialCalendar: 'Social Calendar',
|
||||
enablePolls: 'Straw Polls',
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@ -571,6 +571,40 @@ function generateBlockHtml(type: string, defaults: Record<string, unknown>): str
|
||||
</div>
|
||||
</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:
|
||||
return `<section style="padding: 40px; text-align: center;"><p>Custom block: ${type}</p></section>`;
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@ const roleColors: Record<UserRole, string> = {
|
||||
PAYMENTS_ADMIN: 'green',
|
||||
EVENTS_ADMIN: 'cyan',
|
||||
SOCIAL_ADMIN: 'magenta',
|
||||
POLLS_ADMIN: 'geekblue',
|
||||
USER: 'blue',
|
||||
TEMP: 'default',
|
||||
};
|
||||
|
||||
51
admin/src/components/polls/PollResults.tsx
Normal file
51
admin/src/components/polls/PollResults.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -59,7 +59,6 @@ import {
|
||||
MobileOutlined,
|
||||
DesktopOutlined,
|
||||
CalendarOutlined,
|
||||
ClearOutlined,
|
||||
FormOutlined,
|
||||
ShareAltOutlined,
|
||||
LockOutlined,
|
||||
@ -591,40 +590,6 @@ export default function DocsPage() {
|
||||
const isMobile = !screens.md;
|
||||
const { token } = theme.useToken();
|
||||
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 [config, setConfig] = useState<ServicesConfig | null>(null);
|
||||
@ -800,9 +765,10 @@ export default function DocsPage() {
|
||||
}
|
||||
}, [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(() => {
|
||||
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;
|
||||
|
||||
// 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') {
|
||||
e.preventDefault();
|
||||
if (collab.active) {
|
||||
// In collab mode, auto-save handles persistence — just refresh preview
|
||||
previewIframeRef.current?.contentWindow?.location.reload();
|
||||
// In collab mode, explicitly save current content + refresh preview
|
||||
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 {
|
||||
saveFile();
|
||||
}
|
||||
@ -1603,13 +1583,10 @@ export default function DocsPage() {
|
||||
<Tooltip title="Build static site">
|
||||
<Button type="text" icon={<BuildOutlined />} onClick={confirmAndBuild} loading={building} size="middle" />
|
||||
</Tooltip>
|
||||
<Tooltip title="Reset site to baseline">
|
||||
<Button type="text" danger icon={<ClearOutlined />} onClick={confirmAndReset} loading={resetting} size="middle" />
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</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
|
||||
useEffect(() => {
|
||||
|
||||
@ -912,6 +912,49 @@ export default function MkDocsSettingsPage() {
|
||||
))}
|
||||
</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>
|
||||
),
|
||||
},
|
||||
|
||||
@ -468,6 +468,9 @@ export default function SettingsPage() {
|
||||
<Form.Item label="Advocacy Campaigns" name="enableInfluence" valuePropName="checked" style={{ marginBottom: 12 }}>
|
||||
<Switch />
|
||||
</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 }}>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
|
||||
@ -81,6 +81,7 @@ const roleColors: Record<UserRole, string> = {
|
||||
PAYMENTS_ADMIN: 'green',
|
||||
EVENTS_ADMIN: 'cyan',
|
||||
SOCIAL_ADMIN: 'magenta',
|
||||
POLLS_ADMIN: 'geekblue',
|
||||
USER: 'blue',
|
||||
TEMP: 'default',
|
||||
};
|
||||
|
||||
412
admin/src/pages/influence/StrawPollsPage.tsx
Normal file
412
admin/src/pages/influence/StrawPollsPage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
293
admin/src/pages/public/StrawPollPage.tsx
Normal file
293
admin/src/pages/public/StrawPollPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
admin/src/pages/public/StrawPollsListPage.tsx
Normal file
63
admin/src/pages/public/StrawPollsListPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -460,8 +460,8 @@ export default function SmsSetupPage() {
|
||||
configures auto-start, and launches the server.
|
||||
</Paragraph>
|
||||
<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="Run the setup script with your API key" cmd={`bash ~/sms-server/android/setup.sh ${generatedKey}`} />
|
||||
<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/termux-sms/setup.sh ${generatedKey}`} />
|
||||
</div>
|
||||
<Paragraph style={{ marginTop: 12 }}>
|
||||
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`} />
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
}
|
||||
|
||||
@ -13,7 +13,7 @@ export interface AppOutletContext {
|
||||
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';
|
||||
|
||||
@ -101,7 +101,7 @@ export interface UsersListParams {
|
||||
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
|
||||
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 SYSTEM_ROLES: UserRole[] = ['SUPER_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 ---
|
||||
|
||||
@ -1169,6 +1170,7 @@ export interface SiteSettings {
|
||||
enableMeetingPlanner: boolean;
|
||||
enableTicketedEvents: boolean;
|
||||
enableSocialCalendar: boolean;
|
||||
enablePolls: boolean;
|
||||
enableDocsCollaboration: boolean;
|
||||
requireEventApproval: boolean;
|
||||
autoSyncPeopleToMap: boolean;
|
||||
@ -3356,3 +3358,98 @@ export interface CalendarExportToken {
|
||||
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
135
api/package-lock.json
generated
@ -1656,14 +1656,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz",
|
||||
"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": {
|
||||
"version": "0.5.1",
|
||||
"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",
|
||||
"integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/express": "*"
|
||||
}
|
||||
@ -2102,9 +2095,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"version": "8.18.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
@ -2216,14 +2209,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.2.tgz",
|
||||
"integrity": "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==",
|
||||
"dependencies": {
|
||||
"jackspeak": "^4.2.3"
|
||||
},
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/bcryptjs": {
|
||||
@ -2260,14 +2250,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz",
|
||||
"integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==",
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
@ -2548,6 +2538,7 @@
|
||||
"version": "1.4.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
|
||||
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "0.7.2",
|
||||
"cookie-signature": "1.0.6"
|
||||
@ -2700,15 +2691,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/drizzle-kit": {
|
||||
"version": "0.31.9",
|
||||
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.9.tgz",
|
||||
"integrity": "sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg==",
|
||||
"version": "0.31.10",
|
||||
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz",
|
||||
"integrity": "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@drizzle-team/brocli": "^0.10.2",
|
||||
"@esbuild-kit/esm-loader": "^2.5.5",
|
||||
"esbuild": "^0.25.4",
|
||||
"esbuild-register": "^3.5.0"
|
||||
"tsx": "^4.21.0"
|
||||
},
|
||||
"bin": {
|
||||
"drizzle-kit": "bin.cjs"
|
||||
@ -3426,41 +3417,6 @@
|
||||
"@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": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
@ -3623,9 +3579,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/fastify": {
|
||||
"version": "5.7.4",
|
||||
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.7.4.tgz",
|
||||
"integrity": "sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA==",
|
||||
"version": "5.8.4",
|
||||
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.4.tgz",
|
||||
"integrity": "sha512-sa42J1xylbBAYUWALSBoyXKPDUvM3OoNOibIefA+Oha57FryXKKCZarA1iDntOCWp3O35voZLuDg2mdODXtPzQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@ -3646,7 +3602,7 @@
|
||||
"fast-json-stringify": "^6.0.0",
|
||||
"find-my-way": "^9.0.0",
|
||||
"light-my-request": "^6.0.0",
|
||||
"pino": "^10.1.0",
|
||||
"pino": "^9.14.0 || ^10.1.0",
|
||||
"process-warning": "^5.0.0",
|
||||
"rfdc": "^1.3.1",
|
||||
"secure-json-parse": "^4.0.0",
|
||||
@ -4066,20 +4022,6 @@
|
||||
"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": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||
@ -4407,14 +4349,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.0.tgz",
|
||||
"integrity": "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w==",
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.2"
|
||||
"brace-expansion": "^5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
@ -4542,9 +4484,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz",
|
||||
"integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==",
|
||||
"version": "8.0.4",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz",
|
||||
"integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
@ -4705,9 +4647,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
@ -5000,9 +4942,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.1",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
||||
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
|
||||
"version": "6.14.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
||||
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
@ -5848,10 +5790,9 @@
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||
"license": "ISC",
|
||||
"version": "2.8.3",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
||||
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
@ -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';
|
||||
@ -21,6 +21,7 @@ enum UserRole {
|
||||
PAYMENTS_ADMIN
|
||||
EVENTS_ADMIN
|
||||
SOCIAL_ADMIN
|
||||
POLLS_ADMIN
|
||||
USER
|
||||
TEMP
|
||||
}
|
||||
@ -167,6 +168,13 @@ model User {
|
||||
schedulingPollVotes SchedulingPollVote[] @relation("PollVoter")
|
||||
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
|
||||
participantNeeds ParticipantNeeds? @relation("UserParticipantNeeds")
|
||||
|
||||
@ -962,6 +970,7 @@ model SiteSettings {
|
||||
enableMeetingPlanner Boolean @default(false) @map("enable_meeting_planner")
|
||||
enableTicketedEvents Boolean @default(false) @map("enable_ticketed_events")
|
||||
enableSocialCalendar Boolean @default(false) @map("enable_social_calendar")
|
||||
enablePolls Boolean @default(false) @map("enable_polls")
|
||||
enableDocsCollaboration Boolean @default(false) @map("enable_docs_collaboration")
|
||||
requireEventApproval Boolean @default(true) @map("require_event_approval")
|
||||
autoSyncPeopleToMap Boolean @default(false) @map("auto_sync_people_to_map")
|
||||
@ -1528,6 +1537,7 @@ enum OrderStatus {
|
||||
COMPLETED
|
||||
FAILED
|
||||
REFUNDED
|
||||
DISPUTED
|
||||
}
|
||||
|
||||
enum NotificationType {
|
||||
@ -1552,6 +1562,10 @@ enum NotificationType {
|
||||
shift_cancelled
|
||||
canvass_session_summary
|
||||
reengagement
|
||||
// Straw poll notification types
|
||||
poll_closed
|
||||
poll_results_available
|
||||
poll_challenge
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@ -3427,7 +3441,8 @@ model Payment {
|
||||
|
||||
model PaymentAuditLog {
|
||||
id Int @id @default(autoincrement())
|
||||
paymentId Int @map("payment_id")
|
||||
paymentId Int? @map("payment_id")
|
||||
orderId String? @map("order_id")
|
||||
action String
|
||||
oldStatus String? @map("old_status")
|
||||
newStatus String? @map("new_status")
|
||||
@ -3436,10 +3451,12 @@ model PaymentAuditLog {
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
// 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])
|
||||
|
||||
@@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([createdAt], map: "idx_payment_audit_log_created")
|
||||
@@map("payment_audit_log")
|
||||
@ -3505,6 +3522,7 @@ model Order {
|
||||
influenceCampaignId String? @map("influence_campaign_id")
|
||||
influenceCampaign Campaign? @relation("CampaignDonations", fields: [influenceCampaignId], references: [id], onDelete: SetNull)
|
||||
tickets Ticket[] @relation("TicketOrder")
|
||||
auditLogs PaymentAuditLog[]
|
||||
|
||||
@@index([userId], map: "idx_orders_user")
|
||||
@@index([productId], map: "idx_orders_product")
|
||||
@ -4274,6 +4292,7 @@ model Contact {
|
||||
activities ContactActivity[]
|
||||
smsConversations SmsConversation[] @relation("ContactSmsConversations")
|
||||
pollVotes SchedulingPollVote[] @relation("PollVoteContact")
|
||||
strawPollVotes StrawPollVote[] @relation("StrawPollVoteContact")
|
||||
participantNeeds ParticipantNeeds? @relation("ContactParticipantNeeds")
|
||||
|
||||
@@index([email])
|
||||
@ -4949,6 +4968,9 @@ enum CalendarItemSource {
|
||||
MANUAL
|
||||
ICS_FEED
|
||||
POLL
|
||||
SHIFT
|
||||
MEETING
|
||||
TICKETED_EVENT
|
||||
}
|
||||
|
||||
enum CalendarRecurrenceFrequency {
|
||||
@ -5344,3 +5366,132 @@ model ActionItem {
|
||||
@@index([dueDate])
|
||||
@@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")
|
||||
}
|
||||
|
||||
@ -466,6 +466,32 @@ async function main() {
|
||||
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) {
|
||||
|
||||
@ -38,6 +38,11 @@ const envSchema = z.object({
|
||||
// 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'),
|
||||
|
||||
// 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_ADMIN_EMAIL: z.string().email().default('admin@cmlite.org'),
|
||||
INITIAL_ADMIN_PASSWORD: z.string().min(12).default('REQUIRED_STRONG_PASSWORD_CHANGE_THIS')
|
||||
|
||||
@ -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({
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
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({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 10, // Reduced from 20 to prevent brute force attacks
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { z } from 'zod';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { UserRole, UserStatus } from '@prisma/client';
|
||||
import { authService } from './auth.service';
|
||||
import { loginSchema, registerSchema, refreshSchema } from './auth.schemas';
|
||||
@ -22,6 +23,9 @@ const router = Router();
|
||||
const REFRESH_COOKIE_NAME = 'cml_refresh';
|
||||
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.
|
||||
* 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). */
|
||||
@ -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
|
||||
router.post(
|
||||
'/login',
|
||||
@ -55,6 +106,8 @@ router.post(
|
||||
const result = await authService.login(req.body.email, req.body.password);
|
||||
// Set refresh token as httpOnly cookie (not in response body)
|
||||
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;
|
||||
res.json(responseWithoutRefresh);
|
||||
} catch (err) {
|
||||
@ -281,6 +334,10 @@ router.post(
|
||||
const result = await authService.refreshTokens(refreshToken);
|
||||
// Set new refresh token as httpOnly cookie
|
||||
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;
|
||||
res.json(responseWithoutRefresh);
|
||||
} catch (err) {
|
||||
@ -302,9 +359,11 @@ router.post(
|
||||
await authService.logout(refreshToken);
|
||||
}
|
||||
clearRefreshCookie(req, res);
|
||||
clearSessionCookie(req, res);
|
||||
res.json({ message: 'Logged out' });
|
||||
} catch (err) {
|
||||
clearRefreshCookie(req, res);
|
||||
clearSessionCookie(req, res);
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
|
||||
50
api/src/modules/auth/gitea-sso.routes.ts
Normal file
50
api/src/modules/auth/gitea-sso.routes.ts
Normal 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 };
|
||||
@ -925,7 +925,7 @@ export const sharedCalendarService = {
|
||||
include: {
|
||||
members: {
|
||||
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' }],
|
||||
});
|
||||
|
||||
const memberName = member.user.name || member.user.email;
|
||||
const memberName = member.user.name || 'Member';
|
||||
for (const item of items) {
|
||||
if (item.visibility === CalendarVisibility.PRIVATE || item.visibility === CalendarVisibility.FRIENDS) continue;
|
||||
allItems.push({
|
||||
@ -999,7 +999,7 @@ export const sharedCalendarService = {
|
||||
const sysLayers = filteredLayers.filter(l => l.layerType === CalendarLayerType.SYSTEM);
|
||||
for (const sysLayer of sysLayers) {
|
||||
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) {
|
||||
allItems.push({
|
||||
...si,
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { homepageService } from './homepage.service';
|
||||
import { homepageStats } from '../../services/event-listeners/homepage-stats.listener';
|
||||
|
||||
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 };
|
||||
|
||||
@ -5,6 +5,7 @@ import { validate } from '../../../middleware/validate';
|
||||
import { authenticate } from '../../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||
import { INFLUENCE_ROLES } from '../../../utils/roles';
|
||||
import { eventBus } from '../../../services/event-bus.service';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@ -45,7 +46,22 @@ router.patch(
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
const before = await campaignsService.findById(id);
|
||||
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);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
|
||||
@ -5,6 +5,7 @@ import { validate } from '../../../middleware/validate';
|
||||
import { authenticate } from '../../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||
import { INFLUENCE_ROLES } from '../../../utils/roles';
|
||||
import { eventBus } from '../../../services/event-bus.service';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@ -47,6 +48,12 @@ router.post(
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
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);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
@ -62,6 +69,12 @@ router.put(
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
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);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
@ -75,7 +88,13 @@ router.delete(
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
const campaign = await campaignsService.findById(id);
|
||||
await campaignsService.delete(id);
|
||||
eventBus.publish('campaign.deleted', {
|
||||
campaignId: campaign.id,
|
||||
title: campaign.title,
|
||||
slug: campaign.slug,
|
||||
});
|
||||
res.status(204).send();
|
||||
} catch (err) {
|
||||
next(err);
|
||||
|
||||
@ -8,7 +8,7 @@ import { getAdminEmailsByRole, isNotificationEnabled } from '../../../services/n
|
||||
import { env } from '../../../config/env';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { recordResponseSubmission } from '../../../utils/metrics';
|
||||
import { rocketchatWebhookService } from '../../../services/rocketchat-webhook.service';
|
||||
import { eventBus } from '../../../services/event-bus.service';
|
||||
import type {
|
||||
SubmitResponseInput,
|
||||
ListPublicResponsesInput,
|
||||
@ -102,11 +102,14 @@ export const responsesService = {
|
||||
logger.error('Failed to enqueue response submitted notification:', err);
|
||||
}
|
||||
|
||||
// Notify Rocket.Chat
|
||||
rocketchatWebhookService.onCampaignResponseSubmitted({
|
||||
// Publish response submitted event
|
||||
eventBus.publish('response.submitted', {
|
||||
responseId: response.id,
|
||||
campaignId: campaign.id,
|
||||
campaignTitle: campaign.title,
|
||||
representativeName: data.representativeName,
|
||||
}).catch(() => {});
|
||||
userEmail: data.submittedByEmail,
|
||||
});
|
||||
|
||||
return {
|
||||
id: response.id,
|
||||
|
||||
@ -9,6 +9,7 @@ import { prisma } from '../../config/database';
|
||||
import { siteSettingsService } from '../settings/settings.service';
|
||||
import { isServiceOnline } from '../../utils/health-check';
|
||||
import { generateSlug, generateModeratorToken } from './jitsi.utils';
|
||||
import { eventBus } from '../../services/event-bus.service';
|
||||
|
||||
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);
|
||||
} catch (err) {
|
||||
logger.error('Create meeting failed:', err);
|
||||
@ -226,6 +235,12 @@ router.delete(
|
||||
}
|
||||
|
||||
await prisma.meeting.delete({ where: { id: meetingId } });
|
||||
|
||||
eventBus.publish('meeting.deleted', {
|
||||
meetingId: meeting.id,
|
||||
title: meeting.title,
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
logger.error('Delete meeting failed:', err);
|
||||
|
||||
@ -2,6 +2,7 @@ import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { prisma } from '../../config/database';
|
||||
import { env } from '../../config/env';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { eventBus } from '../../services/event-bus.service';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@ -32,6 +33,13 @@ router.post(
|
||||
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
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
@ -58,6 +66,56 @@ router.post(
|
||||
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
|
||||
logger.debug(`Listmonk webhook: unhandled event type "${eventType}"`);
|
||||
res.json({ ok: true, action: 'ignored', eventType });
|
||||
|
||||
@ -11,8 +11,7 @@ import { recordCanvassVisit, setActiveCanvassSessions } from '../../../utils/met
|
||||
import { notificationQueueService } from '../../../services/notification-queue.service';
|
||||
import { getAdminEmailsByRole, isNotificationEnabled } from '../../../services/notification.helper';
|
||||
import { env } from '../../../config/env';
|
||||
import { rocketchatWebhookService } from '../../../services/rocketchat-webhook.service';
|
||||
import { listmonkEventSyncService } from '../../../services/listmonk-event-sync.service';
|
||||
import { eventBus } from '../../../services/event-bus.service';
|
||||
import { achievementsService } from '../../social/achievements.service';
|
||||
import type {
|
||||
RecordVisitInput,
|
||||
@ -254,20 +253,6 @@ export const canvassService = {
|
||||
// Recalculate cut completion percentage
|
||||
await this.recalculateCutCompletion(session.cutId);
|
||||
|
||||
// Notify Rocket.Chat
|
||||
try {
|
||||
const [rcUser, rcCut, rcVisitCount] = await Promise.all([
|
||||
prisma.user.findUnique({ where: { id: userId }, select: { name: true, email: true } }),
|
||||
prisma.cut.findUnique({ where: { id: session.cutId }, select: { name: true } }),
|
||||
prisma.canvassVisit.count({ where: { sessionId } }),
|
||||
]);
|
||||
rocketchatWebhookService.onCanvassSessionCompleted({
|
||||
userName: rcUser?.name || rcUser?.email || 'Unknown',
|
||||
visitCount: rcVisitCount,
|
||||
cutName: rcCut?.name || undefined,
|
||||
}).catch(() => {});
|
||||
} catch { /* non-critical */ }
|
||||
|
||||
// Notification: volunteer session summary
|
||||
try {
|
||||
if (await isNotificationEnabled('notifyVolunteerSessionSummary')) {
|
||||
@ -315,7 +300,7 @@ export const canvassService = {
|
||||
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 {
|
||||
const [syncUser, syncCut, syncVisitCount, syncOutcomes] = await Promise.all([
|
||||
prisma.user.findUnique({ where: { id: userId }, select: { email: true, name: true } }),
|
||||
@ -328,13 +313,15 @@ export const canvassService = {
|
||||
for (const row of syncOutcomes) {
|
||||
outcomes[row.outcome] = row._count;
|
||||
}
|
||||
listmonkEventSyncService.onCanvassSessionCompleted({
|
||||
email: syncUser.email,
|
||||
name: syncUser.name || syncUser.email,
|
||||
eventBus.publish('canvass.session.completed', {
|
||||
sessionId,
|
||||
userId,
|
||||
userName: syncUser.name || syncUser.email,
|
||||
userEmail: syncUser.email,
|
||||
cutName: syncCut?.name || 'Unknown',
|
||||
visitCount: syncVisitCount,
|
||||
outcomes,
|
||||
}).catch(() => {});
|
||||
});
|
||||
}
|
||||
} catch { /* non-critical */ }
|
||||
|
||||
@ -650,16 +637,16 @@ export const canvassService = {
|
||||
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) {
|
||||
const name = [updatedAddress.firstName, updatedAddress.lastName].filter(Boolean).join(' ');
|
||||
listmonkEventSyncService.onAddressUpdated({
|
||||
eventBus.publish('contact.address.updated', {
|
||||
email: updatedAddress.email,
|
||||
name,
|
||||
supportLevel: updatedAddress.supportLevel,
|
||||
sign: updatedAddress.sign,
|
||||
address: updatedAddress.location.address,
|
||||
}).catch(() => {});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -8,9 +8,7 @@ import { getAdminEmailsByRole, isNotificationEnabled } from '../../../services/n
|
||||
import { env } from '../../../config/env';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { recordShiftSignup } from '../../../utils/metrics';
|
||||
import { rocketchatWebhookService } from '../../../services/rocketchat-webhook.service';
|
||||
import { listmonkEventSyncService } from '../../../services/listmonk-event-sync.service';
|
||||
import { gancioClient } from '../../../services/gancio.client';
|
||||
import { eventBus } from '../../../services/event-bus.service';
|
||||
import { unifiedCalendarService } from '../../events/unified-calendar.service';
|
||||
import { groupService } from '../../social/group.service';
|
||||
import { achievementsService } from '../../social/achievements.service';
|
||||
@ -138,26 +136,17 @@ export const shiftsService = {
|
||||
},
|
||||
});
|
||||
|
||||
// Gancio event sync (fire-and-forget)
|
||||
if (gancioClient.enabled) {
|
||||
gancioClient.createEvent({
|
||||
title: shift.title,
|
||||
description: shift.description,
|
||||
location: shift.location,
|
||||
date: shift.date,
|
||||
startTime: shift.startTime,
|
||||
endTime: shift.endTime,
|
||||
}).then(async (eventId) => {
|
||||
if (eventId) {
|
||||
await prisma.shift.update({
|
||||
where: { id: shift.id },
|
||||
data: { gancioEventId: eventId },
|
||||
});
|
||||
}
|
||||
}).catch((err) => {
|
||||
logger.warn('Gancio sync on shift create failed:', err);
|
||||
});
|
||||
}
|
||||
// Publish shift.created event (listeners: Gancio, Calendar, n8n)
|
||||
eventBus.publish('shift.created', {
|
||||
shiftId: shift.id,
|
||||
title: shift.title,
|
||||
date: new Date(shift.date).toISOString().split('T')[0],
|
||||
startTime: shift.startTime,
|
||||
endTime: shift.endTime,
|
||||
cutId: shift.cutId,
|
||||
cutName: null,
|
||||
createdByUserId: userId,
|
||||
});
|
||||
|
||||
// Bust unified calendar cache
|
||||
unifiedCalendarService.bustCache().catch(() => {});
|
||||
@ -191,19 +180,17 @@ export const shiftsService = {
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
// Gancio event sync (fire-and-forget)
|
||||
if (gancioClient.enabled && shift.gancioEventId) {
|
||||
gancioClient.updateEvent(shift.gancioEventId, {
|
||||
title: shift.title,
|
||||
description: shift.description,
|
||||
location: shift.location,
|
||||
date: shift.date,
|
||||
startTime: shift.startTime,
|
||||
endTime: shift.endTime,
|
||||
}).catch((err) => {
|
||||
logger.warn('Gancio sync on shift update failed:', err);
|
||||
});
|
||||
}
|
||||
// Publish shift.updated event (listeners: Gancio, Calendar, n8n)
|
||||
eventBus.publish('shift.updated', {
|
||||
shiftId: shift.id,
|
||||
title: shift.title,
|
||||
date: new Date(shift.date).toISOString().split('T')[0],
|
||||
startTime: shift.startTime,
|
||||
endTime: shift.endTime,
|
||||
cutId: shift.cutId,
|
||||
cutName: null,
|
||||
changes: Object.keys(data),
|
||||
});
|
||||
|
||||
// Bust unified calendar cache
|
||||
unifiedCalendarService.bustCache().catch(() => {});
|
||||
@ -217,12 +204,12 @@ export const shiftsService = {
|
||||
throw new AppError(404, 'Shift not found', 'SHIFT_NOT_FOUND');
|
||||
}
|
||||
|
||||
// Delete Gancio event before deleting shift (fire-and-forget)
|
||||
if (gancioClient.enabled && existing.gancioEventId) {
|
||||
gancioClient.deleteEvent(existing.gancioEventId).catch((err) => {
|
||||
logger.warn('Gancio sync on shift delete failed:', err);
|
||||
});
|
||||
}
|
||||
// Publish shift.deleted event (listeners: Gancio, Calendar, n8n)
|
||||
eventBus.publish('shift.deleted', {
|
||||
shiftId: id,
|
||||
title: existing.title,
|
||||
date: new Date(existing.date).toISOString().split('T')[0],
|
||||
});
|
||||
|
||||
// Delete associated meeting if exists
|
||||
if (existing.meetingId) {
|
||||
@ -359,13 +346,17 @@ export const shiftsService = {
|
||||
}),
|
||||
]);
|
||||
|
||||
// Listmonk event sync
|
||||
listmonkEventSyncService.onShiftSignup({
|
||||
email: data.userEmail,
|
||||
name: data.userName || data.userEmail,
|
||||
// Publish shift.signup.created event (listeners: Listmonk, RC, CRM, n8n)
|
||||
eventBus.publish('shift.signup.created', {
|
||||
shiftId,
|
||||
shiftTitle: shift.title,
|
||||
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)
|
||||
groupService.syncShiftTeam(shiftId).catch(() => {});
|
||||
@ -551,14 +542,6 @@ export const shiftsService = {
|
||||
}).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
|
||||
try {
|
||||
if (await isNotificationEnabled('notifyAdminShiftSignup')) {
|
||||
@ -651,13 +634,17 @@ export const shiftsService = {
|
||||
|
||||
recordShiftSignup();
|
||||
|
||||
// Listmonk event sync
|
||||
listmonkEventSyncService.onShiftSignup({
|
||||
email: data.email,
|
||||
name: data.name,
|
||||
// Publish shift.signup.created event (listeners: Listmonk, RC, CRM, n8n)
|
||||
eventBus.publish('shift.signup.created', {
|
||||
shiftId,
|
||||
shiftTitle: shift.title,
|
||||
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)
|
||||
groupService.syncShiftTeam(shiftId).catch(() => {});
|
||||
@ -733,14 +720,16 @@ export const shiftsService = {
|
||||
logger.error('Failed to enqueue cancellation notification:', err);
|
||||
}
|
||||
|
||||
// Notify Rocket.Chat of cancellation
|
||||
// Publish shift.signup.cancelled event (listeners: RC, n8n)
|
||||
if (shift) {
|
||||
const shiftDateStr = new Date(shift.date).toLocaleDateString('en-CA', { month: 'short', day: 'numeric' });
|
||||
rocketchatWebhookService.onShiftCancellation({
|
||||
userName: signup.userName || userEmail,
|
||||
eventBus.publish('shift.signup.cancelled', {
|
||||
shiftId,
|
||||
shiftTitle: shift.title,
|
||||
shiftDate: shiftDateStr,
|
||||
}).catch(() => {});
|
||||
shiftDate: new Date(shift.date).toISOString().split('T')[0],
|
||||
userName: signup.userName || userEmail,
|
||||
userEmail,
|
||||
signupType: 'public',
|
||||
});
|
||||
}
|
||||
|
||||
// Notification: admin shift cancellation alert
|
||||
@ -896,14 +885,6 @@ export const shiftsService = {
|
||||
logger.error('Failed to send volunteer shift signup confirmation email:', err);
|
||||
}
|
||||
|
||||
// Notify Rocket.Chat
|
||||
const shiftDateStr = new Date(shift.date).toLocaleDateString('en-CA', { month: 'short', day: 'numeric' });
|
||||
rocketchatWebhookService.onShiftSignup({
|
||||
userName: user.name || user.email,
|
||||
shiftTitle: shift.title,
|
||||
shiftDate: shiftDateStr,
|
||||
}).catch(() => {});
|
||||
|
||||
// Notification: admin shift signup alert
|
||||
try {
|
||||
if (await isNotificationEnabled('notifyAdminShiftSignup')) {
|
||||
@ -980,13 +961,17 @@ export const shiftsService = {
|
||||
logger.error('Failed to schedule shift thank-you:', err);
|
||||
}
|
||||
|
||||
// Listmonk event sync
|
||||
listmonkEventSyncService.onShiftSignup({
|
||||
email: user.email,
|
||||
name: user.name || user.email,
|
||||
// Publish shift.signup.created event (listeners: Listmonk, RC, CRM, n8n)
|
||||
eventBus.publish('shift.signup.created', {
|
||||
shiftId,
|
||||
shiftTitle: shift.title,
|
||||
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)
|
||||
groupService.syncShiftTeam(shiftId).catch(() => {});
|
||||
@ -1060,14 +1045,16 @@ export const shiftsService = {
|
||||
logger.error('Failed to enqueue cancellation notification:', err);
|
||||
}
|
||||
|
||||
// Notify Rocket.Chat of cancellation
|
||||
// Publish shift.signup.cancelled event (listeners: RC, n8n)
|
||||
if (shift) {
|
||||
const shiftDateStr = new Date(shift.date).toLocaleDateString('en-CA', { month: 'short', day: 'numeric' });
|
||||
rocketchatWebhookService.onShiftCancellation({
|
||||
userName: user.name || user.email,
|
||||
eventBus.publish('shift.signup.cancelled', {
|
||||
shiftId,
|
||||
shiftTitle: shift.title,
|
||||
shiftDate: shiftDateStr,
|
||||
}).catch(() => {});
|
||||
shiftDate: new Date(shift.date).toISOString().split('T')[0],
|
||||
userName: user.name || user.email,
|
||||
userEmail: user.email,
|
||||
signupType: 'volunteer',
|
||||
});
|
||||
}
|
||||
|
||||
// Notification: admin shift cancellation alert
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { createReadStream } from 'fs';
|
||||
import { access } from 'fs/promises';
|
||||
import { resolve } from 'path';
|
||||
import { prisma } from '../../../config/database';
|
||||
import { optionalAuth } from '../middleware/auth';
|
||||
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' });
|
||||
}
|
||||
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 {
|
||||
await access(filePath);
|
||||
await access(resolvedPath);
|
||||
} catch {
|
||||
return reply.code(404).send({ message: 'Image file not found' });
|
||||
}
|
||||
|
||||
reply.header('Content-Type', contentType);
|
||||
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 },
|
||||
});
|
||||
|
||||
if (!photo?.thumbnailPath || photo.thumbnailPath.includes('..')) {
|
||||
if (!photo?.thumbnailPath) {
|
||||
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 {
|
||||
await access(photo.thumbnailPath);
|
||||
await access(resolvedThumb);
|
||||
} catch {
|
||||
return reply.code(404).send({ message: 'Thumbnail file not found' });
|
||||
}
|
||||
|
||||
reply.header('Content-Type', 'image/jpeg');
|
||||
reply.header('Cache-Control', 'public, max-age=604800, immutable');
|
||||
return reply.send(createReadStream(photo.thumbnailPath));
|
||||
return reply.send(createReadStream(resolvedThumb));
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@ import { optionalAuth } from '../middleware/auth';
|
||||
import { videoAnalyticsService } from '../services/video-analytics.service';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { z } from 'zod';
|
||||
import { eventBus } from '../../../services/event-bus.service';
|
||||
|
||||
// Validation schemas
|
||||
const recordViewSchema = z.object({
|
||||
@ -62,6 +63,13 @@ export async function videoTrackingRoutes(fastify: FastifyInstance) {
|
||||
referer,
|
||||
});
|
||||
|
||||
eventBus.publish('media.video.viewed', {
|
||||
videoId: String(videoId),
|
||||
videoTitle: '', // Title not available in tracking context
|
||||
userId: userId ?? null,
|
||||
sessionId: String(viewId),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
viewId,
|
||||
|
||||
@ -7,6 +7,7 @@ import { join } from 'path';
|
||||
import { extractVideoMetadata } from '../services/ffprobe.service';
|
||||
import { ThumbnailService } from '../services/thumbnail.service';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { eventBus } from '../../../services/event-bus.service';
|
||||
|
||||
// List videos endpoint (admin only for now)
|
||||
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}`);
|
||||
|
||||
eventBus.publish('media.video.published', {
|
||||
videoId: String(videoId),
|
||||
title: video.title || video.filename || `Video #${videoId}`,
|
||||
publishedByUserId: userId,
|
||||
});
|
||||
|
||||
return { success: true, video };
|
||||
} catch (error: any) {
|
||||
logger.error(`Error publishing video ${videoId}:`, error);
|
||||
@ -233,6 +242,12 @@ export async function videosRoutes(fastify: FastifyInstance) {
|
||||
});
|
||||
|
||||
logger.info(`Video ${videoId} unpublished`);
|
||||
|
||||
eventBus.publish('media.video.unpublished', {
|
||||
videoId: String(videoId),
|
||||
title: video.title || video.filename || `Video #${videoId}`,
|
||||
});
|
||||
|
||||
return { success: true, video };
|
||||
} catch (error: any) {
|
||||
logger.error(`Error unpublishing video ${videoId}:`, error);
|
||||
|
||||
@ -8,7 +8,8 @@ import {
|
||||
import { validate } from '../../middleware/validate';
|
||||
import { authenticate } from '../../middleware/auth.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();
|
||||
|
||||
@ -44,6 +45,14 @@ router.get('/:id', authenticate, async (req: Request, res: Response, next: NextF
|
||||
try {
|
||||
const id = req.params.id as string;
|
||||
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);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
@ -56,10 +65,19 @@ router.post('/', authenticate, requireRole(...EVENTS_ROLES), validate(createActi
|
||||
} 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) => {
|
||||
try {
|
||||
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);
|
||||
res.json(item);
|
||||
} catch (err) { next(err); }
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { validate } from '../../middleware/validate';
|
||||
import { paymentCheckoutRateLimit } from '../../middleware/rate-limit';
|
||||
import { requirePaymentsEnabled } from './payment-settings.service';
|
||||
import { donationPagesService } from './donation-pages.service';
|
||||
import { donationsService } from './donations.service';
|
||||
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
|
||||
router.post(
|
||||
'/:slug/donate',
|
||||
requirePaymentsEnabled,
|
||||
paymentCheckoutRateLimit,
|
||||
validate(donationPageCheckoutSchema),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
|
||||
@ -31,7 +31,7 @@ export const listDonationPagesSchema = z.object({
|
||||
export type ListDonationPagesInput = z.infer<typeof listDonationPagesSchema>;
|
||||
|
||||
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(),
|
||||
name: z.string().max(200).optional(),
|
||||
message: z.string().max(2000).optional(),
|
||||
|
||||
@ -141,10 +141,18 @@ export const donationsService = {
|
||||
},
|
||||
});
|
||||
|
||||
const updated = await prisma.order.update({
|
||||
where: { id: orderId },
|
||||
data: { status: 'REFUNDED' },
|
||||
});
|
||||
// Stripe refund succeeded — update DB. If this fails, the charge.refunded
|
||||
// webhook will reconcile the status as a fallback.
|
||||
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)}`, {
|
||||
orderId,
|
||||
@ -187,8 +195,6 @@ export const donationsService = {
|
||||
'Donation Page': sanitizeCsvValue(o.donationPage?.title || 'General'),
|
||||
'Message': sanitizeCsvValue(o.donorMessage || ''),
|
||||
'Anonymous': o.isAnonymous ? 'Yes' : 'No',
|
||||
'Stripe Payment Intent': o.stripePaymentIntentId || '',
|
||||
'Stripe Checkout Session': o.stripeCheckoutSessionId || '',
|
||||
'Completed At': o.completedAt ? o.completedAt.toISOString() : '',
|
||||
'Order ID': o.id,
|
||||
})), { header: true });
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { prisma } from '../../config/database';
|
||||
import type { PaymentSettings } from '@prisma/client';
|
||||
import type { UpdatePaymentSettingsInput } from './payments.schemas';
|
||||
@ -40,10 +41,16 @@ export const paymentSettingsService = {
|
||||
async update(data: UpdatePaymentSettingsInput): Promise<PaymentSettings> {
|
||||
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) {
|
||||
if (field in toWrite && typeof toWrite[field] === 'string' && toWrite[field]) {
|
||||
toWrite[field] = encrypt(toWrite[field] as string);
|
||||
if (field in toWrite && typeof toWrite[field] === '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);
|
||||
},
|
||||
};
|
||||
|
||||
/** 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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -37,6 +37,7 @@ router.get('/settings', async (_req: Request, res: Response, next: NextFunction)
|
||||
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) {
|
||||
next(err);
|
||||
@ -50,7 +51,14 @@ router.put(
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
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) {
|
||||
next(err);
|
||||
}
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
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 { plansService } from './plans.service';
|
||||
import { productsService } from './products.service';
|
||||
import { donationsService } from './donations.service';
|
||||
import { authenticate } from '../../middleware/auth.middleware';
|
||||
import { validate } from '../../middleware/validate';
|
||||
import { paymentCheckoutRateLimit } from '../../middleware/rate-limit';
|
||||
import {
|
||||
createSubscriptionCheckoutSchema,
|
||||
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)
|
||||
router.post(
|
||||
'/subscribe',
|
||||
requirePaymentsEnabled,
|
||||
paymentCheckoutRateLimit,
|
||||
authenticate,
|
||||
validate(createSubscriptionCheckoutSchema),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
@ -105,6 +108,8 @@ router.post(
|
||||
// POST /api/payments/purchase — create product checkout (guest or logged-in)
|
||||
router.post(
|
||||
'/purchase',
|
||||
requirePaymentsEnabled,
|
||||
paymentCheckoutRateLimit,
|
||||
validate(createProductCheckoutSchema),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
@ -122,6 +127,8 @@ router.post(
|
||||
// POST /api/payments/donate — create donation checkout (no auth required)
|
||||
router.post(
|
||||
'/donate',
|
||||
requirePaymentsEnabled,
|
||||
paymentCheckoutRateLimit,
|
||||
validate(createDonationCheckoutSchema),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
|
||||
@ -84,7 +84,7 @@ export const createProductCheckoutSchema = z.object({
|
||||
// --- Donation ---
|
||||
|
||||
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(),
|
||||
name: z.string().max(200).optional(),
|
||||
message: z.string().max(2000).optional(),
|
||||
@ -111,7 +111,7 @@ export const subscriptionFiltersSchema = z.object({
|
||||
export const orderFiltersSchema = z.object({
|
||||
page: z.coerce.number().int().min(1).default(1),
|
||||
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(),
|
||||
search: z.string().optional(),
|
||||
});
|
||||
|
||||
@ -228,12 +228,16 @@ export const productsService = {
|
||||
/** Create Stripe Checkout for a product purchase */
|
||||
async createProductCheckout(productId: string, buyerEmail: string, buyerName?: string, userId?: string) {
|
||||
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) {
|
||||
throw new Error('Product is sold out');
|
||||
}
|
||||
// Atomic availability check to prevent overselling under concurrency
|
||||
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({
|
||||
mode: 'payment',
|
||||
@ -367,9 +371,16 @@ export const productsService = {
|
||||
await stripe.refunds.create({ payment_intent: order.stripePaymentIntentId });
|
||||
}
|
||||
|
||||
return prisma.order.update({
|
||||
where: { id: orderId },
|
||||
data: { status: 'REFUNDED' },
|
||||
});
|
||||
// Stripe refund succeeded — update DB. If this fails, the charge.refunded
|
||||
// webhook will reconcile the status as a fallback.
|
||||
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.');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@ -259,8 +259,6 @@ export const subscriptionsService = {
|
||||
'Current Period End': s.currentPeriodEnd ? s.currentPeriodEnd.toISOString() : '',
|
||||
'Cancel at Period End': s.cancelAtPeriodEnd ? 'Yes' : 'No',
|
||||
'Cancelled At': s.cancelledAt ? s.cancelledAt.toISOString() : '',
|
||||
'Stripe Subscription ID': s.stripeSubscriptionId || '',
|
||||
'Stripe Customer ID': s.stripeCustomerId || '',
|
||||
'Subscription ID': s.id.toString(),
|
||||
'User ID': s.userId,
|
||||
})), { header: true });
|
||||
|
||||
@ -4,7 +4,7 @@ import { getStripe, getWebhookSecret } from '../../services/stripe.client';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { recordCrmActivity } from '../../utils/crm-activity';
|
||||
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)
|
||||
function getSubscriptionId(invoice: Stripe.Invoice): string | null {
|
||||
@ -48,6 +48,12 @@ export const webhookService = {
|
||||
case 'charge.refunded':
|
||||
await this.handleChargeRefunded(event.data.object as Stripe.Charge);
|
||||
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':
|
||||
await this.handleCheckoutExpired(event.data.object as Stripe.Checkout.Session);
|
||||
break;
|
||||
@ -142,12 +148,12 @@ export const webhookService = {
|
||||
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 } });
|
||||
if (subUser) {
|
||||
listmonkEventSyncService.onSubscriptionActivated({
|
||||
eventBus.publish('payment.subscription.activated', {
|
||||
email: subUser.email,
|
||||
name: subUser.name || '',
|
||||
planName: plan?.name || `Plan ${planId}`,
|
||||
subscriptionId,
|
||||
}).catch(() => {});
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@ -207,13 +213,13 @@ export const webhookService = {
|
||||
|
||||
// Sync to Listmonk Donors list (fire-and-forget)
|
||||
if (updatedOrder.buyerEmail) {
|
||||
listmonkEventSyncService.onProductPurchased({
|
||||
eventBus.publish('payment.product.purchased', {
|
||||
email: updatedOrder.buyerEmail,
|
||||
name: updatedOrder.buyerName || '',
|
||||
productTitle: updatedOrder.product?.title || 'Product',
|
||||
amountCents: updatedOrder.amountCAD,
|
||||
orderId: updatedOrder.id,
|
||||
}).catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
// CRM activity (fire-and-forget)
|
||||
@ -282,12 +288,12 @@ export const webhookService = {
|
||||
|
||||
// Sync to Listmonk Donors list (fire-and-forget)
|
||||
if (order.buyerEmail) {
|
||||
listmonkEventSyncService.onDonationCompleted({
|
||||
eventBus.publish('payment.donation.completed', {
|
||||
email: order.buyerEmail,
|
||||
name: order.buyerName || '',
|
||||
amountCents: order.amountCAD,
|
||||
orderId: order.id,
|
||||
}).catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
// CRM activity (fire-and-forget)
|
||||
@ -518,17 +524,103 @@ export const webhookService = {
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Check payments
|
||||
const payment = await prisma.payment.findFirst({
|
||||
async handleDisputeCreated(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 (payment && payment.status !== 'refunded') {
|
||||
await prisma.payment.update({
|
||||
where: { id: payment.id },
|
||||
data: { status: 'refunded' },
|
||||
if (!order || order.status === 'DISPUTED') return;
|
||||
|
||||
const previousStatus = order.status;
|
||||
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) {
|
||||
@ -562,8 +654,19 @@ export const webhookService = {
|
||||
|
||||
async createAuditLog(action: string, metadata: Record<string, unknown>) {
|
||||
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);
|
||||
} catch (err) {
|
||||
// Audit log failure must not break payment processing
|
||||
logger.error('Failed to create audit log', err);
|
||||
}
|
||||
},
|
||||
|
||||
@ -2,6 +2,7 @@ import { prisma } from '../../config/database';
|
||||
import { redis } from '../../config/redis';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { AppError } from '../../middleware/error-handler';
|
||||
import { eventBus } from '../../services/event-bus.service';
|
||||
import type { Prisma } from '@prisma/client';
|
||||
import type {
|
||||
ListPeopleInput,
|
||||
@ -1053,13 +1054,12 @@ export const peopleService = {
|
||||
});
|
||||
|
||||
if (input.email) {
|
||||
import('../../services/listmonk-event-sync.service').then(({ listmonkEventSyncService }) => {
|
||||
listmonkEventSyncService.onContactTagsChanged({
|
||||
email: input.email!,
|
||||
name: contact.displayName || '',
|
||||
addedTags: initialTags,
|
||||
removedTags: [],
|
||||
}).catch(err => logger.debug('Listmonk tag sync failed on create:', err));
|
||||
eventBus.publish('contact.tags.changed', {
|
||||
email: input.email!,
|
||||
name: contact.displayName || '',
|
||||
contactId: contact.id,
|
||||
addedTags: initialTags,
|
||||
removedTags: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1133,13 +1133,12 @@ export const peopleService = {
|
||||
|
||||
const email = (data.email !== undefined ? (data.email === '' ? null : data.email) : existing.email);
|
||||
if (email) {
|
||||
import('../../services/listmonk-event-sync.service').then(({ listmonkEventSyncService }) => {
|
||||
listmonkEventSyncService.onContactTagsChanged({
|
||||
email,
|
||||
name: contact.displayName || '',
|
||||
addedTags,
|
||||
removedTags,
|
||||
}).catch(err => logger.debug('Listmonk tag sync failed:', err));
|
||||
eventBus.publish('contact.tags.changed', {
|
||||
email,
|
||||
name: contact.displayName || '',
|
||||
contactId: existing.id,
|
||||
addedTags,
|
||||
removedTags,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1358,13 +1357,12 @@ export const peopleService = {
|
||||
|
||||
const mergedEmail = target.email || sourceContact?.email;
|
||||
if (mergedEmail) {
|
||||
import('../../services/listmonk-event-sync.service').then(({ listmonkEventSyncService }) => {
|
||||
listmonkEventSyncService.onContactTagsChanged({
|
||||
email: mergedEmail,
|
||||
name: target.displayName,
|
||||
addedTags: addedToTarget,
|
||||
removedTags: [],
|
||||
}).catch(err => logger.debug('Listmonk tag sync failed on merge:', err));
|
||||
eventBus.publish('contact.tags.changed', {
|
||||
email: mergedEmail,
|
||||
name: target.displayName,
|
||||
contactId: targetId,
|
||||
addedTags: addedToTarget,
|
||||
removedTags: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
123
api/src/modules/polls/polls-public.routes.ts
Normal file
123
api/src/modules/polls/polls-public.routes.ts
Normal 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 };
|
||||
112
api/src/modules/polls/polls-sse.service.ts
Normal file
112
api/src/modules/polls/polls-sse.service.ts
Normal 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();
|
||||
30
api/src/modules/polls/polls-widget.routes.ts
Normal file
30
api/src/modules/polls/polls-widget.routes.ts
Normal 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 };
|
||||
37
api/src/modules/polls/polls.rate-limits.ts
Normal file
37
api/src/modules/polls/polls.rate-limits.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
});
|
||||
121
api/src/modules/polls/polls.routes.ts
Normal file
121
api/src/modules/polls/polls.routes.ts
Normal 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 };
|
||||
67
api/src/modules/polls/polls.schemas.ts
Normal file
67
api/src/modules/polls/polls.schemas.ts
Normal 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>;
|
||||
656
api/src/modules/polls/polls.service.ts
Normal file
656
api/src/modules/polls/polls.service.ts
Normal 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();
|
||||
@ -58,6 +58,7 @@ export const updateSiteSettingsSchema = z.object({
|
||||
enableMeet: z.boolean().optional(),
|
||||
enableMeetingPlanner: z.boolean().optional(),
|
||||
enableTicketedEvents: z.boolean().optional(),
|
||||
enablePolls: z.boolean().optional(),
|
||||
enableSocialCalendar: z.boolean().optional(),
|
||||
enableDocsCollaboration: z.boolean().optional(),
|
||||
requireEventApproval: z.boolean().optional(),
|
||||
|
||||
@ -5,6 +5,7 @@ import { validate } from '../../../middleware/validate';
|
||||
import { smsCampaignsService } from './sms-campaigns.service';
|
||||
import { createSmsCampaignSchema, updateSmsCampaignSchema } from './sms-campaigns.schemas';
|
||||
import { smsQueueService } from '../../../services/sms-queue.service';
|
||||
import { eventBus } from '../../../services/event-bus.service';
|
||||
import { BROADCAST_ROLES } from '../../../utils/roles';
|
||||
|
||||
const router = Router();
|
||||
@ -66,7 +67,17 @@ router.delete('/:id', async (req, res, next) => {
|
||||
// POST /api/sms/campaigns/:id/start — start sending
|
||||
router.post('/:id/start', async (req, res, next) => {
|
||||
try {
|
||||
const campaign = await smsCampaignsService.findById(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);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
@ -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' });
|
||||
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);
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
|
||||
@ -1,9 +1,14 @@
|
||||
import { Router } from 'express';
|
||||
import { authenticate } from '../../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||
import { smsSendRateLimit } from '../../../middleware/rate-limit';
|
||||
import { smsMessagesService } from './sms-messages.service';
|
||||
import { eventBus } from '../../../services/event-bus.service';
|
||||
import { BROADCAST_ROLES } from '../../../utils/roles';
|
||||
|
||||
const MAX_SMS_LENGTH = 1600;
|
||||
const PHONE_DIGITS_RE = /^\d{10,11}$/;
|
||||
|
||||
const router = Router();
|
||||
|
||||
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
|
||||
router.post('/send', async (req, res, next) => {
|
||||
router.post('/send', smsSendRateLimit, async (req, res, next) => {
|
||||
try {
|
||||
const { phone, message } = req.body as { phone?: string; message?: string };
|
||||
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;
|
||||
}
|
||||
const result = await smsMessagesService.sendSingle(phone, message);
|
||||
|
||||
eventBus.publish('sms.message.sent', {
|
||||
messageId: result.id,
|
||||
phone: result.phone,
|
||||
body: result.message,
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
@ -5,7 +5,7 @@ import { friendshipService } from './friendship.service';
|
||||
/** A unified feed item representing any activity type */
|
||||
export interface FeedItem {
|
||||
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;
|
||||
userName: string | null;
|
||||
userEmail: string;
|
||||
@ -56,7 +56,7 @@ export const feedService = {
|
||||
since.setDate(since.getDate() - FEED_MAX_AGE_DAYS);
|
||||
|
||||
// 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.getCampaignEmailActivities(visibleFriendIds, since),
|
||||
this.getCanvassSessionActivities(visibleFriendIds, since),
|
||||
@ -65,6 +65,7 @@ export const feedService = {
|
||||
this.getSpotlightActivities(since),
|
||||
this.getReferralActivities(visibleFriendIds, since),
|
||||
this.getChallengeActivities(since),
|
||||
this.getStrawPollVoteActivities(visibleFriendIds, since),
|
||||
]);
|
||||
|
||||
// Merge and sort by timestamp descending
|
||||
@ -77,6 +78,7 @@ export const feedService = {
|
||||
...spotlights,
|
||||
...referrals,
|
||||
...challenges,
|
||||
...pollVotes,
|
||||
].sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
|
||||
|
||||
// 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,
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
@ -3,6 +3,7 @@ import { requireRole } from '../../middleware/rbac.middleware';
|
||||
import { INFLUENCE_ROLES } from '../../utils/roles';
|
||||
import { impactStoriesService } from './impact-stories.service';
|
||||
import { createStorySchema, updateStorySchema, listStoriesSchema } from './impact-stories.schemas';
|
||||
import { eventBus } from '../../services/event-bus.service';
|
||||
|
||||
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);
|
||||
// Fire-and-forget: notify participants
|
||||
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);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
|
||||
@ -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 */
|
||||
router.get('/map/friends', async (req: Request, res: Response) => {
|
||||
try {
|
||||
|
||||
@ -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 */
|
||||
async getHiddenActivityUserIds(userIds: string[]): Promise<Set<string>> {
|
||||
const hidden = await prisma.privacySettings.findMany({
|
||||
|
||||
@ -25,6 +25,10 @@ const TYPE_TO_PREF: Record<string, string> = {
|
||||
shift_cancelled: 'enableSystemUpdates',
|
||||
canvass_session_summary: 'enableSystemUpdates',
|
||||
reengagement: 'enableSystemUpdates',
|
||||
// Straw poll notification types
|
||||
poll_closed: 'enableSystemUpdates',
|
||||
poll_results_available: 'enableSystemUpdates',
|
||||
poll_challenge: 'enableFriendRequests',
|
||||
};
|
||||
|
||||
export const notificationService = {
|
||||
|
||||
@ -39,6 +39,30 @@ async function requireEventPermission(req: Request, _res: Response, next: NextFu
|
||||
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
|
||||
router.use(authenticate, requireEventPermission);
|
||||
|
||||
@ -73,7 +97,7 @@ router.post('/', validate(createEventSchema), async (req: Request, res: Response
|
||||
});
|
||||
|
||||
// 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 {
|
||||
const event = await ticketedEventsService.findById(req.params.id as string);
|
||||
res.json(event);
|
||||
@ -144,7 +168,7 @@ router.post('/:id/complete', requireRole(...EVENTS_ROLES), async (req: Request,
|
||||
// --- Meeting ---
|
||||
|
||||
// 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 {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.user!.id },
|
||||
@ -159,7 +183,7 @@ router.post('/:id/meeting-token', async (req: Request, res: Response, next: Next
|
||||
// --- 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 {
|
||||
const tier = await ticketedEventsService.addTier(req.params.id as string, req.body);
|
||||
res.status(201).json(tier);
|
||||
@ -167,7 +191,7 @@ router.post('/:id/tiers', validate(createTierSchema), async (req: Request, res:
|
||||
});
|
||||
|
||||
// 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 {
|
||||
const tier = await ticketedEventsService.updateTier(
|
||||
req.params.tierId as string,
|
||||
@ -179,7 +203,7 @@ router.put('/:id/tiers/:tierId', validate(updateTierSchema), async (req: Request
|
||||
});
|
||||
|
||||
// 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 {
|
||||
await ticketedEventsService.deleteTier(req.params.tierId as string, req.params.id as string);
|
||||
res.json({ success: true });
|
||||
@ -189,7 +213,7 @@ router.delete('/:id/tiers/:tierId', async (req: Request, res: Response, next: Ne
|
||||
// --- 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 {
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
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
|
||||
router.get('/:id/checkins', async (req: Request, res: Response, next: NextFunction) => {
|
||||
router.get('/:id/checkins', requireEventOwnership, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
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
|
||||
router.get('/:id/stats', async (req: Request, res: Response, next: NextFunction) => {
|
||||
router.get('/:id/stats', requireEventOwnership, async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const stats = await ticketedEventsService.getEventStats(req.params.id as string);
|
||||
res.json(stats);
|
||||
@ -219,7 +243,7 @@ router.get('/:id/stats', async (req: Request, res: Response, next: NextFunction)
|
||||
});
|
||||
|
||||
// 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 {
|
||||
const ticket = await prisma.ticket.findUnique({
|
||||
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
|
||||
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 {
|
||||
await ticketsService.cancelTicket(req.params.ticketId as string);
|
||||
res.json({ success: true });
|
||||
|
||||
@ -9,6 +9,8 @@ import { getStripe } from '../../services/stripe.client';
|
||||
import { prisma } from '../../config/database';
|
||||
import { env } from '../../config/env';
|
||||
import { AppError } from '../../middleware/error-handler';
|
||||
import { paymentCheckoutRateLimit } from '../../middleware/rate-limit';
|
||||
import { requirePaymentsEnabled } from '../payments/payment-settings.service';
|
||||
|
||||
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
|
||||
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 {
|
||||
const slug = req.params.slug as string;
|
||||
const { tierId, quantity, buyerEmail, buyerName } = req.body;
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { prisma } from '../../config/database';
|
||||
import { TicketedEventStatus, TicketedEventVisibility, EventFormat, Prisma } from '@prisma/client';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { AppError } from '../../middleware/error-handler';
|
||||
import { unifiedCalendarService } from '../events/unified-calendar.service';
|
||||
import { siteSettingsService } from '../settings/settings.service';
|
||||
@ -9,6 +8,7 @@ import { generateSlug as generateMeetingSlug } from '../../utils/slug';
|
||||
import { env } from '../../config/env';
|
||||
import crypto from 'crypto';
|
||||
import { EVENTS_ROLES } from '../../utils/roles';
|
||||
import { eventBus } from '../../services/event-bus.service';
|
||||
|
||||
function generateSlug(title: string): string {
|
||||
return title
|
||||
@ -384,10 +384,19 @@ export const ticketedEventsService = {
|
||||
include: { ticketTiers: true },
|
||||
});
|
||||
|
||||
// Gancio sync + calendar cache bust (fire-and-forget)
|
||||
this.syncToGancio(updated).catch(() => {});
|
||||
// Calendar cache bust (fire-and-forget)
|
||||
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;
|
||||
},
|
||||
|
||||
@ -404,8 +413,18 @@ export const ticketedEventsService = {
|
||||
include: { ticketTiers: true },
|
||||
});
|
||||
|
||||
this.syncToGancio(updated).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;
|
||||
},
|
||||
|
||||
@ -445,12 +464,14 @@ export const ticketedEventsService = {
|
||||
data: { status: 'CANCELLED' },
|
||||
});
|
||||
|
||||
// Delete from Gancio if synced + bust calendar cache
|
||||
if (event.gancioEventId) {
|
||||
this.deleteFromGancio(event.gancioEventId).catch(() => {});
|
||||
}
|
||||
// Calendar cache bust (fire-and-forget)
|
||||
unifiedCalendarService.bustCache().catch(() => {});
|
||||
|
||||
eventBus.publish('ticketed-event.cancelled', {
|
||||
eventId: updated.id,
|
||||
title: event.title,
|
||||
});
|
||||
|
||||
return updated;
|
||||
},
|
||||
|
||||
@ -485,7 +506,10 @@ export const ticketedEventsService = {
|
||||
}
|
||||
|
||||
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 } });
|
||||
@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@ -10,6 +10,7 @@ import { requireRole } from '../../middleware/rbac.middleware';
|
||||
import { hasAnyRole, ADMIN_ROLES, getUserRoles } from '../../utils/roles';
|
||||
import { prisma } from '../../config/database';
|
||||
import { emailService } from '../../services/email.service';
|
||||
import { eventBus } from '../../services/event-bus.service';
|
||||
import { env } from '../../config/env';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { userProvisioningService } from '../../services/user-provisioning/provisioning.service';
|
||||
@ -115,7 +116,7 @@ router.put(
|
||||
}
|
||||
|
||||
// Self-service password change requires current password verification
|
||||
if (isSelf && !isAdminUser && req.body.password) {
|
||||
if (isSelf && req.body.password) {
|
||||
if (!req.body.currentPassword) {
|
||||
res.status(400).json({ error: { message: 'Current password is required to change your password', code: 'CURRENT_PASSWORD_REQUIRED' } });
|
||||
return;
|
||||
@ -183,6 +184,14 @@ router.post(
|
||||
roles: user.roles, status: 'ACTIVE', permissions: user.permissions as Record<string, unknown> | null,
|
||||
}).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 });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
|
||||
@ -4,6 +4,7 @@ import { prisma } from '../../config/database';
|
||||
import { AppError } from '../../middleware/error-handler';
|
||||
import { getPrimaryRole } from '../../utils/roles';
|
||||
import { userProvisioningService } from '../../services/user-provisioning/provisioning.service';
|
||||
import { eventBus } from '../../services/event-bus.service';
|
||||
import { logger } from '../../utils/logger';
|
||||
import type { CMUser } from '../../services/user-provisioning/provisioner.interface';
|
||||
import type { CreateUserInput, UpdateUserInput, ListUsersInput } from './users.schemas';
|
||||
@ -122,6 +123,13 @@ export const usersService = {
|
||||
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;
|
||||
},
|
||||
|
||||
@ -182,6 +190,16 @@ export const usersService = {
|
||||
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;
|
||||
},
|
||||
|
||||
@ -198,6 +216,12 @@ export const usersService = {
|
||||
});
|
||||
|
||||
await prisma.user.delete({ where: { id } });
|
||||
|
||||
eventBus.publish('user.deleted', {
|
||||
userId: existing.id,
|
||||
email: existing.email,
|
||||
name: existing.name || '',
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ import { authenticate } from './middleware/auth.middleware';
|
||||
import { requireRole } from './middleware/rbac.middleware';
|
||||
import { globalRateLimit, healthMetricsRateLimit } from './middleware/rate-limit';
|
||||
import { authRouter } from './modules/auth/auth.routes';
|
||||
import { giteaSsoRouter } from './modules/auth/gitea-sso.routes';
|
||||
import { usersRouter } from './modules/users/users.routes';
|
||||
import { provisioningRouter } from './modules/users/provisioning.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 { listmonkWebhookRouter } from './modules/listmonk/listmonk-webhook.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 { pagesAdminRouter } from './modules/pages/pages-admin.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 { scheduledJobsQueueService } from './services/scheduled-jobs-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 { actionItemsRouter } from './modules/meetings/action-items.routes';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import { docsCollabService } from './modules/docs/docs-collab.service';
|
||||
import { correlationId } from './middleware/correlation-id';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { registerAllEventListeners } from './services/event-listeners';
|
||||
import { eventBus } from './services/event-bus.service';
|
||||
|
||||
const app = express();
|
||||
|
||||
@ -272,6 +280,7 @@ app.get('/api/metrics/internal', async (req, res) => {
|
||||
|
||||
// --- API Routes ---
|
||||
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', provisioningRouter); // User provisioning management (ADMIN roles)
|
||||
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/meeting-planner', meetingPlannerPublicRouter); // Public poll viewing + voting (no auth)
|
||||
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/action-items', actionItemsRouter); // Action items CRUD (EVENTS roles / auth)
|
||||
app.use('/api/qr', qrRouter); // QR code generation (public)
|
||||
@ -390,6 +402,9 @@ async function start() {
|
||||
// Register user provisioning framework
|
||||
registerProvisioners();
|
||||
|
||||
// Register EventBus listeners (Listmonk, RC, CRM, Calendar, n8n, Gancio)
|
||||
registerAllEventListeners();
|
||||
|
||||
// Rebuild SMTP transporter from DB settings (env fallback for empty fields)
|
||||
await emailService.rebuildTransporter();
|
||||
|
||||
@ -399,6 +414,7 @@ async function start() {
|
||||
calendarFeedQueueService.startWorker();
|
||||
scheduledJobsQueueService.startWorker();
|
||||
pollAutoFinalizeQueueService.startWorker();
|
||||
pollAutoCloseQueueService.startWorker();
|
||||
startProxy();
|
||||
|
||||
// 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
|
||||
presenceService.markAllOffline().catch(() => {});
|
||||
sseService.startHeartbeat();
|
||||
pollSseService.startHeartbeat();
|
||||
setInterval(() => presenceService.cleanupStale().catch(() => {}), 60 * 1000); // every 1 min
|
||||
|
||||
// Challenge lifecycle: activate/complete/score every 5 minutes
|
||||
@ -543,6 +560,7 @@ for (const signal of ['SIGTERM', 'SIGINT']) {
|
||||
process.on(signal, async () => {
|
||||
logger.info(`${signal} received, shutting down...`);
|
||||
sseService.closeAll();
|
||||
pollSseService.closeAll();
|
||||
await docsCollabService.shutdown();
|
||||
await stopProxy();
|
||||
await emailQueueService.close();
|
||||
|
||||
@ -5,7 +5,7 @@ import { prisma } from '../config/database';
|
||||
import { logger } from '../utils/logger';
|
||||
import { emailService } from './email.service';
|
||||
import { recordEmailSent, recordEmailFailed, setEmailQueueSize, emailSendDuration } from '../utils/metrics';
|
||||
import { listmonkEventSyncService } from './listmonk-event-sync.service';
|
||||
import { eventBus } from './event-bus.service';
|
||||
|
||||
interface CampaignEmailJobData {
|
||||
campaignEmailId: string;
|
||||
@ -66,13 +66,13 @@ class EmailQueueService {
|
||||
|
||||
if (result.success) {
|
||||
recordEmailSent(campaignId);
|
||||
// Listmonk event sync
|
||||
listmonkEventSyncService.onCampaignEmailSent({
|
||||
// Publish campaign email sent event
|
||||
eventBus.publish('campaign.email.sent', {
|
||||
email: emailData.userEmail,
|
||||
name: emailData.userName,
|
||||
campaignSlug: emailData.campaignTitle,
|
||||
postalCode: emailData.postalCode,
|
||||
}).catch(() => {});
|
||||
});
|
||||
} else {
|
||||
recordEmailFailed(campaignId, 'send_failure');
|
||||
throw new Error(`Failed to send email to ${emailData.recipientEmail}`);
|
||||
|
||||
183
api/src/services/event-bus.service.ts
Normal file
183
api/src/services/event-bus.service.ts
Normal 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();
|
||||
268
api/src/services/event-listeners/calendar-sync.listener.ts
Normal file
268
api/src/services/event-listeners/calendar-sync.listener.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
234
api/src/services/event-listeners/crm-activity.listener.ts
Normal file
234
api/src/services/event-listeners/crm-activity.listener.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
}
|
||||
212
api/src/services/event-listeners/engagement-scoring.listener.ts
Normal file
212
api/src/services/event-listeners/engagement-scoring.listener.ts
Normal 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
190
api/src/services/event-listeners/gancio.listener.ts
Normal file
190
api/src/services/event-listeners/gancio.listener.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
177
api/src/services/event-listeners/homepage-stats.listener.ts
Normal file
177
api/src/services/event-listeners/homepage-stats.listener.ts
Normal 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 [];
|
||||
}
|
||||
},
|
||||
};
|
||||
41
api/src/services/event-listeners/index.ts
Normal file
41
api/src/services/event-listeners/index.ts
Normal 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`);
|
||||
}
|
||||
105
api/src/services/event-listeners/listmonk.listener.ts
Normal file
105
api/src/services/event-listeners/listmonk.listener.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
}
|
||||
55
api/src/services/event-listeners/n8n-webhook.listener.ts
Normal file
55
api/src/services/event-listeners/n8n-webhook.listener.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
118
api/src/services/event-listeners/rocketchat.listener.ts
Normal file
118
api/src/services/event-listeners/rocketchat.listener.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -599,6 +599,51 @@ class GiteaClient {
|
||||
{ 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();
|
||||
|
||||
129
api/src/services/poll-auto-close-queue.service.ts
Normal file
129
api/src/services/poll-auto-close-queue.service.ts
Normal 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();
|
||||
@ -4,7 +4,7 @@ import { env } from '../config/env';
|
||||
import { logger } from '../utils/logger';
|
||||
import { siteSettingsService } from '../modules/settings/settings.service';
|
||||
import { notificationQueueService } from './notification-queue.service';
|
||||
import { listmonkEventSyncService } from './listmonk-event-sync.service';
|
||||
import { eventBus } from './event-bus.service';
|
||||
|
||||
/**
|
||||
* Volunteer Re-Engagement Scanner
|
||||
@ -76,11 +76,11 @@ class ReengagementService {
|
||||
const cooldownSeconds = cooldownDays * 24 * 60 * 60;
|
||||
await redis.set(cooldownKey, '', 'EX', cooldownSeconds);
|
||||
|
||||
// Listmonk event sync: tag as re-engaged
|
||||
listmonkEventSyncService.onReengagementSent({
|
||||
// Publish re-engagement event
|
||||
eventBus.publish('reengagement.sent', {
|
||||
email: volunteer.email,
|
||||
name: volunteer.name || volunteer.email,
|
||||
}).catch(() => {});
|
||||
});
|
||||
|
||||
sent++;
|
||||
} catch (err) {
|
||||
|
||||
@ -58,6 +58,63 @@ class RocketChatWebhookService {
|
||||
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.
|
||||
* Called during service startup.
|
||||
|
||||
@ -4,6 +4,7 @@ import { env } from '../config/env';
|
||||
import { prisma } from '../config/database';
|
||||
import { logger } from '../utils/logger';
|
||||
import { termuxClient } from './termux.client';
|
||||
import { eventBus } from './event-bus.service';
|
||||
|
||||
export interface SmsJobData {
|
||||
recipientId: string; // empty string for notification jobs
|
||||
@ -129,6 +130,13 @@ class SmsQueueService {
|
||||
where: { id: campaignId },
|
||||
data: { totalSent: { increment: 1 } },
|
||||
});
|
||||
|
||||
eventBus.publish('sms.message.sent', {
|
||||
messageId: smsMessage.id,
|
||||
campaignId,
|
||||
phone,
|
||||
body: message,
|
||||
});
|
||||
} else {
|
||||
await prisma.smsCampaign.update({
|
||||
where: { id: campaignId },
|
||||
@ -136,6 +144,23 @@ class SmsQueueService {
|
||||
});
|
||||
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 {
|
||||
// Notification job: just throw on failure for BullMQ retry
|
||||
if (!result.success) {
|
||||
|
||||
@ -2,6 +2,7 @@ import { env } from '../config/env';
|
||||
import { prisma } from '../config/database';
|
||||
import { logger } from '../utils/logger';
|
||||
import { termuxClient } from './termux.client';
|
||||
import { eventBus } from './event-bus.service';
|
||||
import type { SmsResponseType } from '@prisma/client';
|
||||
|
||||
// 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
|
||||
if (conversation) {
|
||||
const updates: Record<string, unknown> = {
|
||||
|
||||
@ -19,7 +19,7 @@ export async function getStripe(): Promise<Stripe> {
|
||||
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');
|
||||
return _stripe;
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import { createHmac } from 'crypto';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Prisma, UserRole } from '@prisma/client';
|
||||
import { prisma } from '../../config/database';
|
||||
import { env } from '../../config/env';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { giteaClient } from '../gitea.client';
|
||||
import { CONTENT_ROLES } from '../../utils/roles';
|
||||
import type { ServiceProvisioner, ProvisionerConfig, ProvisionResult, CMUser } from './provisioner.interface';
|
||||
|
||||
const ROLE_MAP: Record<string, string[]> = {
|
||||
@ -14,9 +15,13 @@ const ROLE_MAP: Record<string, string[]> = {
|
||||
TEMP: [],
|
||||
};
|
||||
|
||||
/** The private docs repo name created by gitea-setup */
|
||||
const DOCS_REPO_NAME = 'changemaker.lite';
|
||||
|
||||
/** Deterministic password — never exposed to users */
|
||||
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}`)
|
||||
.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 {
|
||||
success: true,
|
||||
serviceUserId: String(giteaUser.id),
|
||||
@ -121,6 +129,9 @@ class GiteaProvisioner implements ServiceProvisioner {
|
||||
admin: isAdmin,
|
||||
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> {
|
||||
@ -140,13 +151,57 @@ class GiteaProvisioner implements ServiceProvisioner {
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
@ -16,7 +16,8 @@ const ROLE_MAP: Record<string, string[]> = {
|
||||
|
||||
/** Deterministic password — never exposed to users, only used for RC internal auth */
|
||||
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}`)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
504
api/src/types/events.ts
Normal file
504
api/src/types/events.ts
Normal 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];
|
||||
@ -10,6 +10,7 @@ const ROLE_PRIORITY: Record<string, number> = {
|
||||
PAYMENTS_ADMIN: 4,
|
||||
EVENTS_ADMIN: 4,
|
||||
SOCIAL_ADMIN: 4,
|
||||
POLLS_ADMIN: 4,
|
||||
USER: 2,
|
||||
TEMP: 1,
|
||||
};
|
||||
@ -25,6 +26,7 @@ export const ADMIN_ROLES: UserRole[] = [
|
||||
UserRole.PAYMENTS_ADMIN,
|
||||
UserRole.EVENTS_ADMIN,
|
||||
UserRole.SOCIAL_ADMIN,
|
||||
UserRole.POLLS_ADMIN,
|
||||
];
|
||||
|
||||
// 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 SYSTEM_ROLES: UserRole[] = [UserRole.SUPER_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 */
|
||||
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
Loading…
x
Reference in New Issue
Block a user