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
|
# Generate with: openssl rand -hex 32
|
||||||
ENCRYPTION_KEY=GENERATE_WITH_openssl_rand_hex_32
|
ENCRYPTION_KEY=GENERATE_WITH_openssl_rand_hex_32
|
||||||
|
|
||||||
|
# Gitea SSO cookie signing secret (separate from JWT — falls back to JWT_ACCESS_SECRET if empty)
|
||||||
|
# Generate with: openssl rand -hex 32
|
||||||
|
GITEA_SSO_SECRET=
|
||||||
|
# Salt for deriving deterministic service passwords (Gitea, Rocket.Chat)
|
||||||
|
# Falls back to JWT_ACCESS_SECRET if empty — set a dedicated value to isolate secret rotation
|
||||||
|
# Generate with: openssl rand -hex 32
|
||||||
|
SERVICE_PASSWORD_SALT=
|
||||||
|
|
||||||
# --- Initial Super Admin User (auto-created during database seeding) ---
|
# --- Initial Super Admin User (auto-created during database seeding) ---
|
||||||
# These credentials are used to create the initial super admin account
|
# These credentials are used to create the initial super admin account
|
||||||
# Change these before running the seed script in production
|
# Change these before running the seed script in production
|
||||||
|
|||||||
@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|||||||
|
|
||||||
Changemaker Lite is a self-hosted political campaign platform built with Docker Compose. It consolidates advocacy email campaigns, geographic mapping, volunteer management, and administration into a single TypeScript stack. The primary domain is `cmlite.org`.
|
Changemaker Lite is a self-hosted political campaign platform built with Docker Compose. It consolidates advocacy email campaigns, geographic mapping, volunteer management, and administration into a single TypeScript stack. The primary domain is `cmlite.org`.
|
||||||
|
|
||||||
**Current state:** V2 rebuild substantially complete on the `v2` branch. Core platform operational with Phases 1-14 complete. See `V2_PLAN.md` for the full roadmap.
|
**Current state:** V2 rebuild substantially complete (merged to `main`). Core platform operational with Phases 1-14 complete. See `V2_PLAN.md` for the full roadmap.
|
||||||
|
|
||||||
**Status Summary:**
|
**Status Summary:**
|
||||||
- ✅ Phases 1-14 Complete (Foundation through Monitoring + DevOps)
|
- ✅ Phases 1-14 Complete (Foundation through Monitoring + DevOps)
|
||||||
@ -160,7 +160,7 @@ changemaker.lite/
|
|||||||
The fastest way to deploy. No source code, no compilation:
|
The fastest way to deploy. No source code, no compilation:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://gitea.bnkops.com/admin/changemaker.lite/raw/branch/v2/scripts/install.sh | bash
|
curl -fsSL https://gitea.bnkops.com/admin/changemaker.lite/raw/branch/main/scripts/install.sh | bash
|
||||||
```
|
```
|
||||||
|
|
||||||
This downloads a ~9MB release tarball, runs the config wizard, and sets `IMAGE_TAG=latest`. Then:
|
This downloads a ~9MB release tarball, runs the config wizard, and sets `IMAGE_TAG=latest`. Then:
|
||||||
@ -173,11 +173,10 @@ Pre-built images are pulled from `gitea.bnkops.com/admin` (~2 min). Database mig
|
|||||||
|
|
||||||
### Source Install (Development)
|
### Source Install (Development)
|
||||||
|
|
||||||
1. **Clone repository and checkout v2 branch:**
|
1. **Clone repository:**
|
||||||
```bash
|
```bash
|
||||||
git clone <repo-url> changemaker.lite
|
git clone <repo-url> changemaker.lite
|
||||||
cd changemaker.lite
|
cd changemaker.lite
|
||||||
git checkout v2
|
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Create environment file:**
|
2. **Create environment file:**
|
||||||
@ -321,7 +320,7 @@ docker compose down
|
|||||||
./scripts/upgrade.sh --use-registry --force --skip-backup
|
./scripts/upgrade.sh --use-registry --force --skip-backup
|
||||||
|
|
||||||
# Install from tarball (end-user one-liner)
|
# Install from tarball (end-user one-liner)
|
||||||
curl -fsSL https://gitea.bnkops.com/admin/changemaker.lite/raw/branch/v2/scripts/install.sh | bash
|
curl -fsSL https://gitea.bnkops.com/admin/changemaker.lite/raw/branch/main/scripts/install.sh | bash
|
||||||
```
|
```
|
||||||
|
|
||||||
**Two compose files:**
|
**Two compose files:**
|
||||||
|
|||||||
@ -174,7 +174,7 @@ The tarball contains:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# One-liner
|
# One-liner
|
||||||
curl -fsSL https://gitea.bnkops.com/admin/changemaker.lite/raw/branch/v2/scripts/install.sh | bash
|
curl -fsSL https://gitea.bnkops.com/admin/changemaker.lite/raw/branch/main/scripts/install.sh | bash
|
||||||
|
|
||||||
# Or manual
|
# Or manual
|
||||||
curl -LO https://gitea.bnkops.com/admin/changemaker.lite/releases/latest/download/changemaker-lite-latest.tar.gz
|
curl -LO https://gitea.bnkops.com/admin/changemaker.lite/releases/latest/download/changemaker-lite-latest.tar.gz
|
||||||
|
|||||||
@ -105,7 +105,7 @@ Send SMS campaigns via an Android bridge, sync subscribers to Listmonk for newsl
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# One-command install (downloads pre-built images, runs config wizard)
|
# One-command install (downloads pre-built images, runs config wizard)
|
||||||
curl -fsSL https://gitea.bnkops.com/admin/changemaker.lite/raw/branch/v2/scripts/install.sh | bash
|
curl -fsSL https://gitea.bnkops.com/admin/changemaker.lite/raw/branch/main/scripts/install.sh | bash
|
||||||
|
|
||||||
cd ~/changemaker.lite
|
cd ~/changemaker.lite
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
@ -115,7 +115,7 @@ Or clone and build from source:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone <repo-url> changemaker.lite
|
git clone <repo-url> changemaker.lite
|
||||||
cd changemaker.lite && git checkout v2
|
cd changemaker.lite
|
||||||
|
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# Edit .env -- set passwords, JWT secrets, admin credentials
|
# Edit .env -- set passwords, JWT secrets, admin credentials
|
||||||
|
|||||||
161
SERVICE_INTEGRATIONS.md
Normal file
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
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
|
||||||
"integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
|
"integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@ -1167,9 +1167,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm64": {
|
"node_modules/@rollup/rollup-android-arm64": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz",
|
||||||
"integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
|
"integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -1180,9 +1180,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz",
|
||||||
"integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
|
"integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -1193,9 +1193,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-x64": {
|
"node_modules/@rollup/rollup-darwin-x64": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz",
|
||||||
"integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
|
"integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -1206,9 +1206,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz",
|
||||||
"integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
|
"integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -1219,9 +1219,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz",
|
||||||
"integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
|
"integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -1232,9 +1232,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz",
|
||||||
"integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
|
"integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@ -1245,9 +1245,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz",
|
||||||
"integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
|
"integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@ -1258,9 +1258,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz",
|
||||||
"integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
|
"integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -1271,9 +1271,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz",
|
||||||
"integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
|
"integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -1284,9 +1284,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz",
|
||||||
"integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
|
"integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@ -1297,9 +1297,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz",
|
||||||
"integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
|
"integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@ -1310,9 +1310,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz",
|
||||||
"integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
|
"integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@ -1323,9 +1323,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz",
|
||||||
"integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
|
"integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@ -1336,9 +1336,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz",
|
||||||
"integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
|
"integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@ -1349,9 +1349,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz",
|
||||||
"integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
|
"integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@ -1362,9 +1362,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz",
|
||||||
"integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
|
"integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@ -1375,9 +1375,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz",
|
||||||
"integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
|
"integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -1388,9 +1388,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz",
|
||||||
"integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
|
"integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -1401,9 +1401,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz",
|
||||||
"integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
|
"integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -1414,9 +1414,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz",
|
||||||
"integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
|
"integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -1427,9 +1427,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz",
|
||||||
"integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
|
"integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -1440,9 +1440,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz",
|
||||||
"integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
|
"integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@ -1453,9 +1453,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz",
|
||||||
"integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
|
"integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -1466,9 +1466,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz",
|
||||||
"integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
|
"integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -2261,9 +2261,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dompurify": {
|
"node_modules/dompurify": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
|
||||||
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
|
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@types/trusted-types": "^2.0.7"
|
"@types/trusted-types": "^2.0.7"
|
||||||
}
|
}
|
||||||
@ -2860,9 +2860,9 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
@ -3651,9 +3651,9 @@
|
|||||||
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
|
"integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg=="
|
||||||
},
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.57.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
|
||||||
"integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
|
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/estree": "1.0.8"
|
"@types/estree": "1.0.8"
|
||||||
@ -3666,31 +3666,31 @@
|
|||||||
"npm": ">=8.0.0"
|
"npm": ">=8.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rollup/rollup-android-arm-eabi": "4.57.1",
|
"@rollup/rollup-android-arm-eabi": "4.60.1",
|
||||||
"@rollup/rollup-android-arm64": "4.57.1",
|
"@rollup/rollup-android-arm64": "4.60.1",
|
||||||
"@rollup/rollup-darwin-arm64": "4.57.1",
|
"@rollup/rollup-darwin-arm64": "4.60.1",
|
||||||
"@rollup/rollup-darwin-x64": "4.57.1",
|
"@rollup/rollup-darwin-x64": "4.60.1",
|
||||||
"@rollup/rollup-freebsd-arm64": "4.57.1",
|
"@rollup/rollup-freebsd-arm64": "4.60.1",
|
||||||
"@rollup/rollup-freebsd-x64": "4.57.1",
|
"@rollup/rollup-freebsd-x64": "4.60.1",
|
||||||
"@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
|
"@rollup/rollup-linux-arm-gnueabihf": "4.60.1",
|
||||||
"@rollup/rollup-linux-arm-musleabihf": "4.57.1",
|
"@rollup/rollup-linux-arm-musleabihf": "4.60.1",
|
||||||
"@rollup/rollup-linux-arm64-gnu": "4.57.1",
|
"@rollup/rollup-linux-arm64-gnu": "4.60.1",
|
||||||
"@rollup/rollup-linux-arm64-musl": "4.57.1",
|
"@rollup/rollup-linux-arm64-musl": "4.60.1",
|
||||||
"@rollup/rollup-linux-loong64-gnu": "4.57.1",
|
"@rollup/rollup-linux-loong64-gnu": "4.60.1",
|
||||||
"@rollup/rollup-linux-loong64-musl": "4.57.1",
|
"@rollup/rollup-linux-loong64-musl": "4.60.1",
|
||||||
"@rollup/rollup-linux-ppc64-gnu": "4.57.1",
|
"@rollup/rollup-linux-ppc64-gnu": "4.60.1",
|
||||||
"@rollup/rollup-linux-ppc64-musl": "4.57.1",
|
"@rollup/rollup-linux-ppc64-musl": "4.60.1",
|
||||||
"@rollup/rollup-linux-riscv64-gnu": "4.57.1",
|
"@rollup/rollup-linux-riscv64-gnu": "4.60.1",
|
||||||
"@rollup/rollup-linux-riscv64-musl": "4.57.1",
|
"@rollup/rollup-linux-riscv64-musl": "4.60.1",
|
||||||
"@rollup/rollup-linux-s390x-gnu": "4.57.1",
|
"@rollup/rollup-linux-s390x-gnu": "4.60.1",
|
||||||
"@rollup/rollup-linux-x64-gnu": "4.57.1",
|
"@rollup/rollup-linux-x64-gnu": "4.60.1",
|
||||||
"@rollup/rollup-linux-x64-musl": "4.57.1",
|
"@rollup/rollup-linux-x64-musl": "4.60.1",
|
||||||
"@rollup/rollup-openbsd-x64": "4.57.1",
|
"@rollup/rollup-openbsd-x64": "4.60.1",
|
||||||
"@rollup/rollup-openharmony-arm64": "4.57.1",
|
"@rollup/rollup-openharmony-arm64": "4.60.1",
|
||||||
"@rollup/rollup-win32-arm64-msvc": "4.57.1",
|
"@rollup/rollup-win32-arm64-msvc": "4.60.1",
|
||||||
"@rollup/rollup-win32-ia32-msvc": "4.57.1",
|
"@rollup/rollup-win32-ia32-msvc": "4.60.1",
|
||||||
"@rollup/rollup-win32-x64-gnu": "4.57.1",
|
"@rollup/rollup-win32-x64-gnu": "4.60.1",
|
||||||
"@rollup/rollup-win32-x64-msvc": "4.57.1",
|
"@rollup/rollup-win32-x64-msvc": "4.60.1",
|
||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -3993,10 +3993,9 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/yaml": {
|
"node_modules/yaml": {
|
||||||
"version": "2.8.2",
|
"version": "2.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
||||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
||||||
"license": "ISC",
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"yaml": "bin.mjs"
|
"yaml": "bin.mjs"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -112,6 +112,7 @@ import {
|
|||||||
EVENTS_ROLES,
|
EVENTS_ROLES,
|
||||||
SOCIAL_ROLES,
|
SOCIAL_ROLES,
|
||||||
SYSTEM_ROLES,
|
SYSTEM_ROLES,
|
||||||
|
POLLS_ROLES,
|
||||||
} from '@/types/api';
|
} from '@/types/api';
|
||||||
import { isAdmin } from '@/utils/roles';
|
import { isAdmin } from '@/utils/roles';
|
||||||
import QuickJoinPage from '@/pages/public/QuickJoinPage';
|
import QuickJoinPage from '@/pages/public/QuickJoinPage';
|
||||||
@ -132,6 +133,7 @@ import ReferralAdminPage from '@/pages/social/ReferralAdminPage';
|
|||||||
import SpotlightAdminPage from '@/pages/social/SpotlightAdminPage';
|
import SpotlightAdminPage from '@/pages/social/SpotlightAdminPage';
|
||||||
import ChallengesAdminPage from '@/pages/social/ChallengesAdminPage';
|
import ChallengesAdminPage from '@/pages/social/ChallengesAdminPage';
|
||||||
import ImpactStoriesPage from '@/pages/influence/ImpactStoriesPage';
|
import ImpactStoriesPage from '@/pages/influence/ImpactStoriesPage';
|
||||||
|
import StrawPollsPage from '@/pages/influence/StrawPollsPage';
|
||||||
import ReferralsPage from '@/pages/volunteer/ReferralsPage';
|
import ReferralsPage from '@/pages/volunteer/ReferralsPage';
|
||||||
import ChallengesPage from '@/pages/volunteer/ChallengesPage';
|
import ChallengesPage from '@/pages/volunteer/ChallengesPage';
|
||||||
import ChallengeDetailPage from '@/pages/volunteer/ChallengeDetailPage';
|
import ChallengeDetailPage from '@/pages/volunteer/ChallengeDetailPage';
|
||||||
@ -142,6 +144,8 @@ import MeetingAgendaPage from '@/pages/MeetingAgendaPage';
|
|||||||
import ActionItemsPage from '@/pages/ActionItemsPage';
|
import ActionItemsPage from '@/pages/ActionItemsPage';
|
||||||
import SchedulingPollPage from '@/pages/public/SchedulingPollPage';
|
import SchedulingPollPage from '@/pages/public/SchedulingPollPage';
|
||||||
import PollsListPage from '@/pages/public/PollsListPage';
|
import PollsListPage from '@/pages/public/PollsListPage';
|
||||||
|
import StrawPollPage from '@/pages/public/StrawPollPage';
|
||||||
|
import StrawPollsListPage from '@/pages/public/StrawPollsListPage';
|
||||||
import JitsiAuthPage from '@/pages/JitsiAuthPage';
|
import JitsiAuthPage from '@/pages/JitsiAuthPage';
|
||||||
import SchedulingCalendarPage from '@/pages/SchedulingCalendarPage';
|
import SchedulingCalendarPage from '@/pages/SchedulingCalendarPage';
|
||||||
import AdminCalendarViewPage from '@/pages/AdminCalendarViewPage';
|
import AdminCalendarViewPage from '@/pages/AdminCalendarViewPage';
|
||||||
@ -276,6 +280,14 @@ export default function App() {
|
|||||||
<Route index element={<SchedulingPollPage />} />
|
<Route index element={<SchedulingPollPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
{/* Straw polls — feature-gated */}
|
||||||
|
<Route path="/straw-polls" element={<FeatureGate feature="enablePolls"><PublicLayout /></FeatureGate>}>
|
||||||
|
<Route index element={<StrawPollsListPage />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="/straw-poll/:slug" element={<FeatureGate feature="enablePolls"><PublicLayout /></FeatureGate>}>
|
||||||
|
<Route index element={<StrawPollPage />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
{/* Public ticketed event pages — feature-gated */}
|
{/* Public ticketed event pages — feature-gated */}
|
||||||
<Route path="/event/:slug" element={<FeatureGate feature="enableTicketedEvents"><PublicLayout /></FeatureGate>}>
|
<Route path="/event/:slug" element={<FeatureGate feature="enableTicketedEvents"><PublicLayout /></FeatureGate>}>
|
||||||
<Route index element={<TicketedEventDetailPage />} />
|
<Route index element={<TicketedEventDetailPage />} />
|
||||||
@ -562,6 +574,14 @@ export default function App() {
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="influence/straw-polls"
|
||||||
|
element={
|
||||||
|
<ProtectedRoute requiredRoles={POLLS_ROLES}>
|
||||||
|
<StrawPollsPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="listmonk"
|
path="listmonk"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@ -71,6 +71,7 @@ import {
|
|||||||
MEDIA_ROLES,
|
MEDIA_ROLES,
|
||||||
PAYMENTS_ROLES,
|
PAYMENTS_ROLES,
|
||||||
SOCIAL_ROLES,
|
SOCIAL_ROLES,
|
||||||
|
POLLS_ROLES,
|
||||||
} from '@/types/api';
|
} from '@/types/api';
|
||||||
import { buildHomeUrl, resolveNavUrl } from '@/lib/service-url';
|
import { buildHomeUrl, resolveNavUrl } from '@/lib/service-url';
|
||||||
import type { NavItem } from '@/types/api';
|
import type { NavItem } from '@/types/api';
|
||||||
@ -187,6 +188,7 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null, use
|
|||||||
{ key: '/app/responses', icon: <MessageOutlined />, label: badges?.pendingResponses ? <Badge count={badges.pendingResponses} size="small" offset={[8, 0]}>Responses</Badge> : 'Responses' },
|
{ key: '/app/responses', icon: <MessageOutlined />, label: badges?.pendingResponses ? <Badge count={badges.pendingResponses} size="small" offset={[8, 0]}>Responses</Badge> : 'Responses' },
|
||||||
{ key: '/app/influence/effectiveness', icon: <LineChartOutlined />, label: 'Effectiveness' },
|
{ key: '/app/influence/effectiveness', icon: <LineChartOutlined />, label: 'Effectiveness' },
|
||||||
{ key: '/app/influence/stories', icon: <TrophyOutlined />, label: 'Impact Stories' },
|
{ key: '/app/influence/stories', icon: <TrophyOutlined />, label: 'Impact Stories' },
|
||||||
|
...(settings?.enablePolls !== false && can(POLLS_ROLES) ? [{ key: '/app/influence/straw-polls', icon: <BarChartOutlined />, label: 'Straw Polls' }] : []),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -712,6 +714,7 @@ export default function AppLayout() {
|
|||||||
</Dropdown>
|
</Dropdown>
|
||||||
</Header>
|
</Header>
|
||||||
<Content
|
<Content
|
||||||
|
id="app-content-area"
|
||||||
style={{
|
style={{
|
||||||
margin: fullBleed ? 0 : (isMobile ? 12 : 24),
|
margin: fullBleed ? 0 : (isMobile ? 12 : 24),
|
||||||
padding: fullBleed ? 0 : (isMobile ? 16 : 24),
|
padding: fullBleed ? 0 : (isMobile ? 16 : 24),
|
||||||
@ -719,6 +722,7 @@ export default function AppLayout() {
|
|||||||
borderRadius: fullBleed ? 0 : token.borderRadiusLG,
|
borderRadius: fullBleed ? 0 : token.borderRadiusLG,
|
||||||
minHeight: 280,
|
minHeight: 280,
|
||||||
overflow: fullBleed ? 'hidden' : undefined,
|
overflow: fullBleed ? 'hidden' : undefined,
|
||||||
|
position: 'relative',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Outlet context={{ setPageHeader } satisfies AppOutletContext} />
|
<Outlet context={{ setPageHeader } satisfies AppOutletContext} />
|
||||||
|
|||||||
@ -22,10 +22,11 @@ const FEATURE_LABELS: Record<string, string> = {
|
|||||||
enableMeetingPlanner: 'Meeting Planner',
|
enableMeetingPlanner: 'Meeting Planner',
|
||||||
enableTicketedEvents: 'Ticketed Events',
|
enableTicketedEvents: 'Ticketed Events',
|
||||||
enableSocialCalendar: 'Social Calendar',
|
enableSocialCalendar: 'Social Calendar',
|
||||||
|
enablePolls: 'Straw Polls',
|
||||||
};
|
};
|
||||||
|
|
||||||
interface FeatureGateProps {
|
interface FeatureGateProps {
|
||||||
feature: keyof Pick<SiteSettings, 'enableInfluence' | 'enableMap' | 'enableLandingPages' | 'enableNewsletter' | 'enableMediaFeatures' | 'enablePayments' | 'enableGalleryAds' | 'enablePeople' | 'enableEvents' | 'enableSocial' | 'enableMeet' | 'enableMeetingPlanner' | 'enableTicketedEvents' | 'enableSocialCalendar'>;
|
feature: keyof Pick<SiteSettings, 'enableInfluence' | 'enableMap' | 'enableLandingPages' | 'enableNewsletter' | 'enableMediaFeatures' | 'enablePayments' | 'enableGalleryAds' | 'enablePeople' | 'enableEvents' | 'enableSocial' | 'enableMeet' | 'enableMeetingPlanner' | 'enableTicketedEvents' | 'enableSocialCalendar' | 'enablePolls'>;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -571,6 +571,40 @@ function generateBlockHtml(type: string, defaults: Record<string, unknown>): str
|
|||||||
</div>
|
</div>
|
||||||
</section>`;
|
</section>`;
|
||||||
}
|
}
|
||||||
|
case 'straw-poll-inline': {
|
||||||
|
const pollSlug = (defaults.pollSlug as string) || '';
|
||||||
|
return `
|
||||||
|
<section style="padding: 60px 40px;">
|
||||||
|
<div class="straw-poll-inline"
|
||||||
|
data-poll-slug="${pollSlug}"
|
||||||
|
data-show-results="true"
|
||||||
|
style="max-width: 500px; margin: 0 auto;">
|
||||||
|
<div style="background: linear-gradient(135deg, #1890ff 0%, #096dd9 100%); border-radius: 12px; padding: 32px; text-align: center; color: #fff;">
|
||||||
|
<svg style="width: 64px; height: 64px; margin-bottom: 16px; opacity: 0.9;" fill="currentColor" viewBox="0 0 1024 1024">
|
||||||
|
<path d="M160 960h128V480H160v480zm256 0h128V320H416v640zm256 0h128V160H672v800z"/>
|
||||||
|
</svg>
|
||||||
|
<p style="margin: 0; font-size: 1.2rem; font-weight: 600;">Straw Poll (Inline)</p>
|
||||||
|
<p style="margin: 8px 0 0; font-size: 0.9rem; opacity: 0.85;">${pollSlug || 'Set poll slug in block properties'}</p>
|
||||||
|
<p style="margin: 12px 0 0; font-size: 0.75rem; opacity: 0.6; font-style: italic;">Inline voting widget renders on published page</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>`;
|
||||||
|
}
|
||||||
|
case 'straw-poll-card': {
|
||||||
|
const pollSlug = (defaults.pollSlug as string) || '';
|
||||||
|
return `
|
||||||
|
<section style="padding: 40px;">
|
||||||
|
<div class="straw-poll-card"
|
||||||
|
data-poll-slug="${pollSlug}"
|
||||||
|
style="max-width: 400px; margin: 0 auto;">
|
||||||
|
<div style="background: linear-gradient(135deg, #722ed1 0%, #531dab 100%); border-radius: 12px; padding: 24px; text-align: center; color: #fff;">
|
||||||
|
<p style="margin: 0; font-size: 1rem; font-weight: 600;">Straw Poll (Card Link)</p>
|
||||||
|
<p style="margin: 8px 0 0; font-size: 0.85rem; opacity: 0.85;">${pollSlug || 'Set poll slug in block properties'}</p>
|
||||||
|
<p style="margin: 8px 0 0; font-size: 0.75rem; opacity: 0.6; font-style: italic;">Preview card with vote link renders on published page</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>`;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return `<section style="padding: 40px; text-align: center;"><p>Custom block: ${type}</p></section>`;
|
return `<section style="padding: 40px; text-align: center;"><p>Custom block: ${type}</p></section>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,7 @@ const roleColors: Record<UserRole, string> = {
|
|||||||
PAYMENTS_ADMIN: 'green',
|
PAYMENTS_ADMIN: 'green',
|
||||||
EVENTS_ADMIN: 'cyan',
|
EVENTS_ADMIN: 'cyan',
|
||||||
SOCIAL_ADMIN: 'magenta',
|
SOCIAL_ADMIN: 'magenta',
|
||||||
|
POLLS_ADMIN: 'geekblue',
|
||||||
USER: 'blue',
|
USER: 'blue',
|
||||||
TEMP: 'default',
|
TEMP: 'default',
|
||||||
};
|
};
|
||||||
|
|||||||
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,
|
MobileOutlined,
|
||||||
DesktopOutlined,
|
DesktopOutlined,
|
||||||
CalendarOutlined,
|
CalendarOutlined,
|
||||||
ClearOutlined,
|
|
||||||
FormOutlined,
|
FormOutlined,
|
||||||
ShareAltOutlined,
|
ShareAltOutlined,
|
||||||
LockOutlined,
|
LockOutlined,
|
||||||
@ -591,40 +590,6 @@ export default function DocsPage() {
|
|||||||
const isMobile = !screens.md;
|
const isMobile = !screens.md;
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
const { building, confirmAndBuild, isSuperAdmin } = useMkDocsBuild();
|
const { building, confirmAndBuild, isSuperAdmin } = useMkDocsBuild();
|
||||||
const [resetting, setResetting] = useState(false);
|
|
||||||
|
|
||||||
const confirmAndReset = useCallback(() => {
|
|
||||||
if (!isSuperAdmin) return;
|
|
||||||
Modal.confirm({
|
|
||||||
title: 'Reset Documentation Site',
|
|
||||||
content: (
|
|
||||||
<div>
|
|
||||||
<p>This will reset all documentation content to a baseline template.</p>
|
|
||||||
<p><strong>Preserved:</strong> header config, analytics tracking, hooks, assets, stylesheets, blog.</p>
|
|
||||||
<p><strong>Deleted:</strong> all custom content pages.</p>
|
|
||||||
<p>A backup will be created automatically.</p>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
okText: 'Reset Site',
|
|
||||||
okButtonProps: { danger: true },
|
|
||||||
onOk: async () => {
|
|
||||||
setResetting(true);
|
|
||||||
try {
|
|
||||||
const { data } = await api.post('/docs/reset');
|
|
||||||
message.success(`Site reset complete. ${data.filesReset} files reset, ${data.filesPreserved} preserved.`);
|
|
||||||
// Refresh file tree
|
|
||||||
const treeRes = await api.get('/docs/files');
|
|
||||||
setFileTree(treeRes.data.tree || []);
|
|
||||||
setSelectedFile(null);
|
|
||||||
setFileContent('');
|
|
||||||
} catch {
|
|
||||||
message.error('Failed to reset documentation site');
|
|
||||||
} finally {
|
|
||||||
setResetting(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}, [isSuperAdmin]);
|
|
||||||
|
|
||||||
const [fileTree, setFileTree] = useState<FileNode[]>(() => getCachedTree() || []);
|
const [fileTree, setFileTree] = useState<FileNode[]>(() => getCachedTree() || []);
|
||||||
const [config, setConfig] = useState<ServicesConfig | null>(null);
|
const [config, setConfig] = useState<ServicesConfig | null>(null);
|
||||||
@ -800,9 +765,10 @@ export default function DocsPage() {
|
|||||||
}
|
}
|
||||||
}, [fileContentCache, messageApi]);
|
}, [fileContentCache, messageApi]);
|
||||||
|
|
||||||
// Handle navigation state from command palette — auto-select a file
|
// Handle navigation state from command palette or metadata page — auto-select a file
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const selectFile = (location.state as { selectFile?: string } | null)?.selectFile;
|
const state = location.state as { selectFile?: string; openFile?: string } | null;
|
||||||
|
const selectFile = state?.selectFile || state?.openFile;
|
||||||
if (!selectFile || loading) return;
|
if (!selectFile || loading) return;
|
||||||
|
|
||||||
// Expand parent directories so the file is visible in the tree
|
// Expand parent directories so the file is visible in the tree
|
||||||
@ -855,8 +821,22 @@ export default function DocsPage() {
|
|||||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (collab.active) {
|
if (collab.active) {
|
||||||
// In collab mode, auto-save handles persistence — just refresh preview
|
// In collab mode, explicitly save current content + refresh preview
|
||||||
previewIframeRef.current?.contentWindow?.location.reload();
|
if (selectedFile && collab.yText) {
|
||||||
|
const content = collab.yText.toString();
|
||||||
|
api.put(`/docs/files/${selectedFile}`, { content })
|
||||||
|
.then(() => messageApi.success('Saved'))
|
||||||
|
.catch(() => messageApi.error('Save failed'));
|
||||||
|
}
|
||||||
|
// Refresh preview with cache-buster
|
||||||
|
if (previewIframeRef.current && selectedFile) {
|
||||||
|
const url = filePathToMkDocsUrl(selectedFile);
|
||||||
|
setTimeout(() => {
|
||||||
|
if (previewIframeRef.current) {
|
||||||
|
previewIframeRef.current.src = url + '?_t=' + Date.now();
|
||||||
|
}
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
saveFile();
|
saveFile();
|
||||||
}
|
}
|
||||||
@ -1603,13 +1583,10 @@ export default function DocsPage() {
|
|||||||
<Tooltip title="Build static site">
|
<Tooltip title="Build static site">
|
||||||
<Button type="text" icon={<BuildOutlined />} onClick={confirmAndBuild} loading={building} size="middle" />
|
<Button type="text" icon={<BuildOutlined />} onClick={confirmAndBuild} loading={building} size="middle" />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip title="Reset site to baseline">
|
|
||||||
<Button type="text" danger icon={<ClearOutlined />} onClick={confirmAndReset} loading={resetting} size="middle" />
|
|
||||||
</Tooltip>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
), [layout, dirty, saving, saveFile, refreshPreview, mkdocsDirectUrl, token.colorBorderSecondary, isSuperAdmin, building, confirmAndBuild, resetting, confirmAndReset]);
|
), [layout, dirty, saving, saveFile, refreshPreview, mkdocsDirectUrl, token.colorBorderSecondary, isSuperAdmin, building, confirmAndBuild]);
|
||||||
|
|
||||||
// Inject header
|
// Inject header
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -912,6 +912,49 @@ export default function MkDocsSettingsPage() {
|
|||||||
))}
|
))}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{isSuperAdmin && (
|
||||||
|
<Card
|
||||||
|
title={<span style={{ color: token.colorError }}>Danger Zone</span>}
|
||||||
|
style={{ marginTop: 24, borderColor: token.colorError }}
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
Reset all documentation content to a baseline template. A backup is created automatically.
|
||||||
|
Preserved: header config, analytics tracking, hooks, assets, stylesheets, blog.
|
||||||
|
</Typography.Text>
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
onClick={() => {
|
||||||
|
Modal.confirm({
|
||||||
|
title: 'Reset Documentation Site',
|
||||||
|
content: (
|
||||||
|
<div>
|
||||||
|
<p>This will reset all documentation content to a baseline template.</p>
|
||||||
|
<p><strong>Preserved:</strong> header config, analytics, hooks, assets, stylesheets, blog.</p>
|
||||||
|
<p><strong>Deleted:</strong> all custom content pages.</p>
|
||||||
|
<p>A backup will be created automatically.</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
okText: 'Reset Site',
|
||||||
|
okButtonProps: { danger: true },
|
||||||
|
onOk: async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.post('/docs/reset');
|
||||||
|
message.success(`Site reset complete. ${data.filesReset} files reset, ${data.filesPreserved} preserved.`);
|
||||||
|
} catch {
|
||||||
|
message.error('Failed to reset documentation site');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset Site to Baseline
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -468,6 +468,9 @@ export default function SettingsPage() {
|
|||||||
<Form.Item label="Advocacy Campaigns" name="enableInfluence" valuePropName="checked" style={{ marginBottom: 12 }}>
|
<Form.Item label="Advocacy Campaigns" name="enableInfluence" valuePropName="checked" style={{ marginBottom: 12 }}>
|
||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item label="Straw Polls" name="enablePolls" valuePropName="checked" extra="Quick opinion polls with public landers and MkDocs widgets" style={{ marginBottom: 12 }}>
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
<Form.Item label="Map & Canvassing" name="enableMap" valuePropName="checked" style={{ marginBottom: 12 }}>
|
<Form.Item label="Map & Canvassing" name="enableMap" valuePropName="checked" style={{ marginBottom: 12 }}>
|
||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@ -81,6 +81,7 @@ const roleColors: Record<UserRole, string> = {
|
|||||||
PAYMENTS_ADMIN: 'green',
|
PAYMENTS_ADMIN: 'green',
|
||||||
EVENTS_ADMIN: 'cyan',
|
EVENTS_ADMIN: 'cyan',
|
||||||
SOCIAL_ADMIN: 'magenta',
|
SOCIAL_ADMIN: 'magenta',
|
||||||
|
POLLS_ADMIN: 'geekblue',
|
||||||
USER: 'blue',
|
USER: 'blue',
|
||||||
TEMP: 'default',
|
TEMP: 'default',
|
||||||
};
|
};
|
||||||
|
|||||||
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.
|
configures auto-start, and launches the server.
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
<div style={{ background: 'rgba(0,0,0,0.1)', padding: 12, borderRadius: 6 }}>
|
<div style={{ background: 'rgba(0,0,0,0.1)', padding: 12, borderRadius: 6 }}>
|
||||||
<CmdLine comment="Clone the SMS server (first time only)" cmd="pkg install -y git && git clone https://gitea.bnkops.com/admin/campaign_connector.git ~/sms-server" />
|
<CmdLine comment="Clone the SMS server (first time only)" cmd="pkg install -y git && git clone --depth 1 --filter=blob:none --sparse https://gitea.bnkops.com/admin/changemaker.lite.git ~/sms-server && cd ~/sms-server && git sparse-checkout set termux-sms" />
|
||||||
<CmdLine comment="Run the setup script with your API key" cmd={`bash ~/sms-server/android/setup.sh ${generatedKey}`} />
|
<CmdLine comment="Run the setup script with your API key" cmd={`bash ~/sms-server/termux-sms/setup.sh ${generatedKey}`} />
|
||||||
</div>
|
</div>
|
||||||
<Paragraph style={{ marginTop: 12 }}>
|
<Paragraph style={{ marginTop: 12 }}>
|
||||||
The script will:
|
The script will:
|
||||||
@ -517,7 +517,7 @@ export default function SmsSetupPage() {
|
|||||||
<CmdLine comment="Update key and restart service" cmd={`sed -i '/SMS_API_SECRET/d' ~/.bashrc && echo 'export SMS_API_SECRET="${generatedKey}"' >> ~/.bashrc && source ~/.bashrc && sv restart sms-api`} />
|
<CmdLine comment="Update key and restart service" cmd={`sed -i '/SMS_API_SECRET/d' ~/.bashrc && echo 'export SMS_API_SECRET="${generatedKey}"' >> ~/.bashrc && source ~/.bashrc && sv restart sms-api`} />
|
||||||
</div>
|
</div>
|
||||||
<Paragraph type="secondary" style={{ marginTop: 8, marginBottom: 0 }}>
|
<Paragraph type="secondary" style={{ marginTop: 8, marginBottom: 0 }}>
|
||||||
If <Text code>sv</Text> is not installed yet, run the full setup: <Text code copyable={{ text: `cd ~/sms-server && git pull && bash android/setup-services.sh` }}>cd ~/sms-server && git pull && bash android/setup-services.sh</Text>
|
If <Text code>sv</Text> is not installed yet, run the full setup: <Text code copyable={{ text: `cd ~/sms-server && git pull && bash termux-sms/setup-services.sh` }}>cd ~/sms-server && git pull && bash termux-sms/setup-services.sh</Text>
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,7 +13,7 @@ export interface AppOutletContext {
|
|||||||
setPageHeader: (config: PageHeaderConfig | null) => void;
|
setPageHeader: (config: PageHeaderConfig | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UserRole = 'SUPER_ADMIN' | 'INFLUENCE_ADMIN' | 'MAP_ADMIN' | 'BROADCAST_ADMIN' | 'CONTENT_ADMIN' | 'MEDIA_ADMIN' | 'PAYMENTS_ADMIN' | 'EVENTS_ADMIN' | 'SOCIAL_ADMIN' | 'USER' | 'TEMP';
|
export type UserRole = 'SUPER_ADMIN' | 'INFLUENCE_ADMIN' | 'MAP_ADMIN' | 'BROADCAST_ADMIN' | 'CONTENT_ADMIN' | 'MEDIA_ADMIN' | 'PAYMENTS_ADMIN' | 'EVENTS_ADMIN' | 'SOCIAL_ADMIN' | 'POLLS_ADMIN' | 'USER' | 'TEMP';
|
||||||
|
|
||||||
export type UserStatus = 'ACTIVE' | 'INACTIVE' | 'SUSPENDED' | 'EXPIRED' | 'PENDING_VERIFICATION' | 'PENDING_APPROVAL';
|
export type UserStatus = 'ACTIVE' | 'INACTIVE' | 'SUSPENDED' | 'EXPIRED' | 'PENDING_VERIFICATION' | 'PENDING_APPROVAL';
|
||||||
|
|
||||||
@ -101,7 +101,7 @@ export interface UsersListParams {
|
|||||||
status?: UserStatus;
|
status?: UserStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ADMIN_ROLES: UserRole[] = ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN', 'BROADCAST_ADMIN', 'CONTENT_ADMIN', 'MEDIA_ADMIN', 'PAYMENTS_ADMIN', 'EVENTS_ADMIN', 'SOCIAL_ADMIN'];
|
export const ADMIN_ROLES: UserRole[] = ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN', 'BROADCAST_ADMIN', 'CONTENT_ADMIN', 'MEDIA_ADMIN', 'PAYMENTS_ADMIN', 'EVENTS_ADMIN', 'SOCIAL_ADMIN', 'POLLS_ADMIN'];
|
||||||
|
|
||||||
// Module-specific role groups
|
// Module-specific role groups
|
||||||
export const INFLUENCE_ROLES: UserRole[] = ['SUPER_ADMIN', 'INFLUENCE_ADMIN'];
|
export const INFLUENCE_ROLES: UserRole[] = ['SUPER_ADMIN', 'INFLUENCE_ADMIN'];
|
||||||
@ -114,6 +114,7 @@ export const EVENTS_ROLES: UserRole[] = ['SUPER_ADMIN', 'EVENTS_ADMIN'];
|
|||||||
export const SOCIAL_ROLES: UserRole[] = ['SUPER_ADMIN', 'SOCIAL_ADMIN'];
|
export const SOCIAL_ROLES: UserRole[] = ['SUPER_ADMIN', 'SOCIAL_ADMIN'];
|
||||||
export const SYSTEM_ROLES: UserRole[] = ['SUPER_ADMIN'];
|
export const SYSTEM_ROLES: UserRole[] = ['SUPER_ADMIN'];
|
||||||
export const SCHEDULING_ROLES: UserRole[] = ['SUPER_ADMIN', 'MAP_ADMIN', 'EVENTS_ADMIN'];
|
export const SCHEDULING_ROLES: UserRole[] = ['SUPER_ADMIN', 'MAP_ADMIN', 'EVENTS_ADMIN'];
|
||||||
|
export const POLLS_ROLES: UserRole[] = ['SUPER_ADMIN', 'POLLS_ADMIN', 'INFLUENCE_ADMIN'];
|
||||||
|
|
||||||
// --- User Provisioning ---
|
// --- User Provisioning ---
|
||||||
|
|
||||||
@ -1169,6 +1170,7 @@ export interface SiteSettings {
|
|||||||
enableMeetingPlanner: boolean;
|
enableMeetingPlanner: boolean;
|
||||||
enableTicketedEvents: boolean;
|
enableTicketedEvents: boolean;
|
||||||
enableSocialCalendar: boolean;
|
enableSocialCalendar: boolean;
|
||||||
|
enablePolls: boolean;
|
||||||
enableDocsCollaboration: boolean;
|
enableDocsCollaboration: boolean;
|
||||||
requireEventApproval: boolean;
|
requireEventApproval: boolean;
|
||||||
autoSyncPeopleToMap: boolean;
|
autoSyncPeopleToMap: boolean;
|
||||||
@ -3356,3 +3358,98 @@ export interface CalendarExportToken {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Straw Polls =====
|
||||||
|
|
||||||
|
export type StrawPollType = 'SINGLE_CHOICE' | 'YES_NO_ABSTAIN';
|
||||||
|
export type StrawPollStatus = 'DRAFT' | 'ACTIVE' | 'CLOSED' | 'ARCHIVED';
|
||||||
|
export type StrawPollIdentityMode = 'ANONYMOUS' | 'TOKEN_GATED' | 'AUTHENTICATED' | 'MIXED';
|
||||||
|
export type StrawPollResultVisibility = 'LIVE' | 'AFTER_VOTE' | 'AFTER_CLOSE' | 'CREATOR_ONLY' | 'PUBLIC_ALWAYS';
|
||||||
|
|
||||||
|
export const STRAW_POLL_STATUS_COLORS: Record<StrawPollStatus, string> = {
|
||||||
|
DRAFT: 'default',
|
||||||
|
ACTIVE: 'green',
|
||||||
|
CLOSED: 'orange',
|
||||||
|
ARCHIVED: 'red',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STRAW_POLL_STATUS_LABELS: Record<StrawPollStatus, string> = {
|
||||||
|
DRAFT: 'Draft',
|
||||||
|
ACTIVE: 'Active',
|
||||||
|
CLOSED: 'Closed',
|
||||||
|
ARCHIVED: 'Archived',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STRAW_POLL_TYPE_LABELS: Record<StrawPollType, string> = {
|
||||||
|
SINGLE_CHOICE: 'Single Choice',
|
||||||
|
YES_NO_ABSTAIN: 'Yes / No / Abstain',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STRAW_POLL_IDENTITY_LABELS: Record<StrawPollIdentityMode, string> = {
|
||||||
|
ANONYMOUS: 'Anonymous',
|
||||||
|
TOKEN_GATED: 'Token-Gated',
|
||||||
|
AUTHENTICATED: 'Login Required',
|
||||||
|
MIXED: 'Mixed',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STRAW_POLL_VISIBILITY_LABELS: Record<StrawPollResultVisibility, string> = {
|
||||||
|
LIVE: 'Live Results',
|
||||||
|
AFTER_VOTE: 'After Voting',
|
||||||
|
AFTER_CLOSE: 'After Close',
|
||||||
|
CREATOR_ONLY: 'Creator Only',
|
||||||
|
PUBLIC_ALWAYS: 'Public Always',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface StrawPollOption {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
sortOrder: number;
|
||||||
|
voteCount?: number;
|
||||||
|
_count?: { votes: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StrawPollVote {
|
||||||
|
id: string;
|
||||||
|
optionId: string;
|
||||||
|
userId: string | null;
|
||||||
|
voterName: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
user?: { id: string; name: string | null; email: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StrawPollComment {
|
||||||
|
id: string;
|
||||||
|
authorName: string;
|
||||||
|
content: string;
|
||||||
|
userId: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StrawPoll {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
type: StrawPollType;
|
||||||
|
status: StrawPollStatus;
|
||||||
|
identityMode: StrawPollIdentityMode;
|
||||||
|
resultVisibility: StrawPollResultVisibility;
|
||||||
|
allowComments: boolean;
|
||||||
|
closesAt: string | null;
|
||||||
|
closeThreshold: number | null;
|
||||||
|
autoCloseJobId: string | null;
|
||||||
|
isPrivate: boolean;
|
||||||
|
createdByUserId: string;
|
||||||
|
createdBy?: { id: string; name: string | null; email: string };
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
options?: StrawPollOption[];
|
||||||
|
votes?: StrawPollVote[];
|
||||||
|
comments?: StrawPollComment[];
|
||||||
|
_count?: { votes: number; comments: number; options: number };
|
||||||
|
// Public view extras
|
||||||
|
totalVotes?: number;
|
||||||
|
showResults?: boolean;
|
||||||
|
hasVoted?: boolean;
|
||||||
|
requiresAuth?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
135
api/package-lock.json
generated
135
api/package-lock.json
generated
@ -1656,14 +1656,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.0.tgz",
|
||||||
"integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow=="
|
"integrity": "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow=="
|
||||||
},
|
},
|
||||||
"node_modules/@isaacs/cliui": {
|
|
||||||
"version": "9.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz",
|
|
||||||
"integrity": "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@js-temporal/polyfill": {
|
"node_modules/@js-temporal/polyfill": {
|
||||||
"version": "0.5.1",
|
"version": "0.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/@js-temporal/polyfill/-/polyfill-0.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/@js-temporal/polyfill/-/polyfill-0.5.1.tgz",
|
||||||
@ -1901,6 +1893,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
|
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
|
||||||
"integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
|
"integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/express": "*"
|
"@types/express": "*"
|
||||||
}
|
}
|
||||||
@ -2102,9 +2095,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "8.17.1",
|
"version": "8.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"fast-uri": "^3.0.1",
|
"fast-uri": "^3.0.1",
|
||||||
@ -2216,14 +2209,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/balanced-match": {
|
"node_modules/balanced-match": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||||
"integrity": "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==",
|
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||||
"dependencies": {
|
|
||||||
"jackspeak": "^4.2.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "20 || >=22"
|
"node": "18 || 20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/bcryptjs": {
|
"node_modules/bcryptjs": {
|
||||||
@ -2260,14 +2250,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "5.0.2",
|
"version": "5.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||||
"integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==",
|
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^4.0.2"
|
"balanced-match": "^4.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "20 || >=22"
|
"node": "18 || 20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/buffer-equal-constant-time": {
|
"node_modules/buffer-equal-constant-time": {
|
||||||
@ -2548,6 +2538,7 @@
|
|||||||
"version": "1.4.7",
|
"version": "1.4.7",
|
||||||
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
|
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
|
||||||
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
|
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
|
||||||
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cookie": "0.7.2",
|
"cookie": "0.7.2",
|
||||||
"cookie-signature": "1.0.6"
|
"cookie-signature": "1.0.6"
|
||||||
@ -2700,15 +2691,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/drizzle-kit": {
|
"node_modules/drizzle-kit": {
|
||||||
"version": "0.31.9",
|
"version": "0.31.10",
|
||||||
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.9.tgz",
|
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz",
|
||||||
"integrity": "sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg==",
|
"integrity": "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@drizzle-team/brocli": "^0.10.2",
|
"@drizzle-team/brocli": "^0.10.2",
|
||||||
"@esbuild-kit/esm-loader": "^2.5.5",
|
"@esbuild-kit/esm-loader": "^2.5.5",
|
||||||
"esbuild": "^0.25.4",
|
"esbuild": "^0.25.4",
|
||||||
"esbuild-register": "^3.5.0"
|
"tsx": "^4.21.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"drizzle-kit": "bin.cjs"
|
"drizzle-kit": "bin.cjs"
|
||||||
@ -3426,41 +3417,6 @@
|
|||||||
"@esbuild/win32-x64": "0.27.3"
|
"@esbuild/win32-x64": "0.27.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/esbuild-register": {
|
|
||||||
"version": "3.6.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/esbuild-register/-/esbuild-register-3.6.0.tgz",
|
|
||||||
"integrity": "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==",
|
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
|
||||||
"debug": "^4.3.4"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"esbuild": ">=0.12 <1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/esbuild-register/node_modules/debug": {
|
|
||||||
"version": "4.4.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
|
||||||
"ms": "^2.1.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"supports-color": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/esbuild-register/node_modules/ms": {
|
|
||||||
"version": "2.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
|
||||||
"dev": true
|
|
||||||
},
|
|
||||||
"node_modules/escape-html": {
|
"node_modules/escape-html": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||||
@ -3623,9 +3579,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/fastify": {
|
"node_modules/fastify": {
|
||||||
"version": "5.7.4",
|
"version": "5.8.4",
|
||||||
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.7.4.tgz",
|
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.4.tgz",
|
||||||
"integrity": "sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA==",
|
"integrity": "sha512-sa42J1xylbBAYUWALSBoyXKPDUvM3OoNOibIefA+Oha57FryXKKCZarA1iDntOCWp3O35voZLuDg2mdODXtPzQ==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@ -3646,7 +3602,7 @@
|
|||||||
"fast-json-stringify": "^6.0.0",
|
"fast-json-stringify": "^6.0.0",
|
||||||
"find-my-way": "^9.0.0",
|
"find-my-way": "^9.0.0",
|
||||||
"light-my-request": "^6.0.0",
|
"light-my-request": "^6.0.0",
|
||||||
"pino": "^10.1.0",
|
"pino": "^9.14.0 || ^10.1.0",
|
||||||
"process-warning": "^5.0.0",
|
"process-warning": "^5.0.0",
|
||||||
"rfdc": "^1.3.1",
|
"rfdc": "^1.3.1",
|
||||||
"secure-json-parse": "^4.0.0",
|
"secure-json-parse": "^4.0.0",
|
||||||
@ -4066,20 +4022,6 @@
|
|||||||
"url": "https://github.com/sponsors/dmonad"
|
"url": "https://github.com/sponsors/dmonad"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/jackspeak": {
|
|
||||||
"version": "4.2.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz",
|
|
||||||
"integrity": "sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==",
|
|
||||||
"dependencies": {
|
|
||||||
"@isaacs/cliui": "^9.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "20 || >=22"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/jiti": {
|
"node_modules/jiti": {
|
||||||
"version": "2.6.1",
|
"version": "2.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||||
@ -4407,14 +4349,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "10.2.0",
|
"version": "10.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||||
"integrity": "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w==",
|
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^5.0.2"
|
"brace-expansion": "^5.0.5"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "20 || >=22"
|
"node": "18 || 20 || >=22"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
@ -4542,9 +4484,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nodemailer": {
|
"node_modules/nodemailer": {
|
||||||
"version": "8.0.1",
|
"version": "8.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz",
|
||||||
"integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==",
|
"integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
@ -4705,9 +4647,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/path-to-regexp": {
|
"node_modules/path-to-regexp": {
|
||||||
"version": "0.1.12",
|
"version": "0.1.13",
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
||||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="
|
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="
|
||||||
},
|
},
|
||||||
"node_modules/pathe": {
|
"node_modules/pathe": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
@ -5000,9 +4942,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.14.1",
|
"version": "6.14.2",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
||||||
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
|
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"side-channel": "^1.1.0"
|
"side-channel": "^1.1.0"
|
||||||
},
|
},
|
||||||
@ -5848,10 +5790,9 @@
|
|||||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="
|
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="
|
||||||
},
|
},
|
||||||
"node_modules/yaml": {
|
"node_modules/yaml": {
|
||||||
"version": "2.8.2",
|
"version": "2.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
||||||
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
||||||
"license": "ISC",
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"yaml": "bin.mjs"
|
"yaml": "bin.mjs"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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
|
PAYMENTS_ADMIN
|
||||||
EVENTS_ADMIN
|
EVENTS_ADMIN
|
||||||
SOCIAL_ADMIN
|
SOCIAL_ADMIN
|
||||||
|
POLLS_ADMIN
|
||||||
USER
|
USER
|
||||||
TEMP
|
TEMP
|
||||||
}
|
}
|
||||||
@ -167,6 +168,13 @@ model User {
|
|||||||
schedulingPollVotes SchedulingPollVote[] @relation("PollVoter")
|
schedulingPollVotes SchedulingPollVote[] @relation("PollVoter")
|
||||||
schedulingPollComments SchedulingPollComment[] @relation("PollCommenter")
|
schedulingPollComments SchedulingPollComment[] @relation("PollCommenter")
|
||||||
|
|
||||||
|
// Straw polls
|
||||||
|
strawPollsCreated StrawPoll[] @relation("StrawPollCreator")
|
||||||
|
strawPollVotes StrawPollVote[] @relation("StrawPollVoter")
|
||||||
|
strawPollComments StrawPollComment[] @relation("StrawPollCommenter")
|
||||||
|
strawPollChallengesSent StrawPollChallenge[] @relation("StrawPollChallenger")
|
||||||
|
strawPollChallengesReceived StrawPollChallenge[] @relation("StrawPollChallenged")
|
||||||
|
|
||||||
// Participant needs
|
// Participant needs
|
||||||
participantNeeds ParticipantNeeds? @relation("UserParticipantNeeds")
|
participantNeeds ParticipantNeeds? @relation("UserParticipantNeeds")
|
||||||
|
|
||||||
@ -962,6 +970,7 @@ model SiteSettings {
|
|||||||
enableMeetingPlanner Boolean @default(false) @map("enable_meeting_planner")
|
enableMeetingPlanner Boolean @default(false) @map("enable_meeting_planner")
|
||||||
enableTicketedEvents Boolean @default(false) @map("enable_ticketed_events")
|
enableTicketedEvents Boolean @default(false) @map("enable_ticketed_events")
|
||||||
enableSocialCalendar Boolean @default(false) @map("enable_social_calendar")
|
enableSocialCalendar Boolean @default(false) @map("enable_social_calendar")
|
||||||
|
enablePolls Boolean @default(false) @map("enable_polls")
|
||||||
enableDocsCollaboration Boolean @default(false) @map("enable_docs_collaboration")
|
enableDocsCollaboration Boolean @default(false) @map("enable_docs_collaboration")
|
||||||
requireEventApproval Boolean @default(true) @map("require_event_approval")
|
requireEventApproval Boolean @default(true) @map("require_event_approval")
|
||||||
autoSyncPeopleToMap Boolean @default(false) @map("auto_sync_people_to_map")
|
autoSyncPeopleToMap Boolean @default(false) @map("auto_sync_people_to_map")
|
||||||
@ -1528,6 +1537,7 @@ enum OrderStatus {
|
|||||||
COMPLETED
|
COMPLETED
|
||||||
FAILED
|
FAILED
|
||||||
REFUNDED
|
REFUNDED
|
||||||
|
DISPUTED
|
||||||
}
|
}
|
||||||
|
|
||||||
enum NotificationType {
|
enum NotificationType {
|
||||||
@ -1552,6 +1562,10 @@ enum NotificationType {
|
|||||||
shift_cancelled
|
shift_cancelled
|
||||||
canvass_session_summary
|
canvass_session_summary
|
||||||
reengagement
|
reengagement
|
||||||
|
// Straw poll notification types
|
||||||
|
poll_closed
|
||||||
|
poll_results_available
|
||||||
|
poll_challenge
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -3427,7 +3441,8 @@ model Payment {
|
|||||||
|
|
||||||
model PaymentAuditLog {
|
model PaymentAuditLog {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
paymentId Int @map("payment_id")
|
paymentId Int? @map("payment_id")
|
||||||
|
orderId String? @map("order_id")
|
||||||
action String
|
action String
|
||||||
oldStatus String? @map("old_status")
|
oldStatus String? @map("old_status")
|
||||||
newStatus String? @map("new_status")
|
newStatus String? @map("new_status")
|
||||||
@ -3436,10 +3451,12 @@ model PaymentAuditLog {
|
|||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
payment Payment @relation(fields: [paymentId], references: [id])
|
payment Payment? @relation(fields: [paymentId], references: [id])
|
||||||
|
order Order? @relation(fields: [orderId], references: [id])
|
||||||
user User? @relation("PaymentAuditUser", fields: [userId], references: [id])
|
user User? @relation("PaymentAuditUser", fields: [userId], references: [id])
|
||||||
|
|
||||||
@@index([paymentId], map: "idx_payment_audit_log_payment")
|
@@index([paymentId], map: "idx_payment_audit_log_payment")
|
||||||
|
@@index([orderId], map: "idx_payment_audit_log_order")
|
||||||
@@index([action], map: "idx_payment_audit_log_action")
|
@@index([action], map: "idx_payment_audit_log_action")
|
||||||
@@index([createdAt], map: "idx_payment_audit_log_created")
|
@@index([createdAt], map: "idx_payment_audit_log_created")
|
||||||
@@map("payment_audit_log")
|
@@map("payment_audit_log")
|
||||||
@ -3505,6 +3522,7 @@ model Order {
|
|||||||
influenceCampaignId String? @map("influence_campaign_id")
|
influenceCampaignId String? @map("influence_campaign_id")
|
||||||
influenceCampaign Campaign? @relation("CampaignDonations", fields: [influenceCampaignId], references: [id], onDelete: SetNull)
|
influenceCampaign Campaign? @relation("CampaignDonations", fields: [influenceCampaignId], references: [id], onDelete: SetNull)
|
||||||
tickets Ticket[] @relation("TicketOrder")
|
tickets Ticket[] @relation("TicketOrder")
|
||||||
|
auditLogs PaymentAuditLog[]
|
||||||
|
|
||||||
@@index([userId], map: "idx_orders_user")
|
@@index([userId], map: "idx_orders_user")
|
||||||
@@index([productId], map: "idx_orders_product")
|
@@index([productId], map: "idx_orders_product")
|
||||||
@ -4274,6 +4292,7 @@ model Contact {
|
|||||||
activities ContactActivity[]
|
activities ContactActivity[]
|
||||||
smsConversations SmsConversation[] @relation("ContactSmsConversations")
|
smsConversations SmsConversation[] @relation("ContactSmsConversations")
|
||||||
pollVotes SchedulingPollVote[] @relation("PollVoteContact")
|
pollVotes SchedulingPollVote[] @relation("PollVoteContact")
|
||||||
|
strawPollVotes StrawPollVote[] @relation("StrawPollVoteContact")
|
||||||
participantNeeds ParticipantNeeds? @relation("ContactParticipantNeeds")
|
participantNeeds ParticipantNeeds? @relation("ContactParticipantNeeds")
|
||||||
|
|
||||||
@@index([email])
|
@@index([email])
|
||||||
@ -4949,6 +4968,9 @@ enum CalendarItemSource {
|
|||||||
MANUAL
|
MANUAL
|
||||||
ICS_FEED
|
ICS_FEED
|
||||||
POLL
|
POLL
|
||||||
|
SHIFT
|
||||||
|
MEETING
|
||||||
|
TICKETED_EVENT
|
||||||
}
|
}
|
||||||
|
|
||||||
enum CalendarRecurrenceFrequency {
|
enum CalendarRecurrenceFrequency {
|
||||||
@ -5344,3 +5366,132 @@ model ActionItem {
|
|||||||
@@index([dueDate])
|
@@index([dueDate])
|
||||||
@@map("action_items")
|
@@map("action_items")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// STRAW POLLS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
enum StrawPollType {
|
||||||
|
SINGLE_CHOICE
|
||||||
|
YES_NO_ABSTAIN
|
||||||
|
}
|
||||||
|
|
||||||
|
enum StrawPollStatus {
|
||||||
|
DRAFT
|
||||||
|
ACTIVE
|
||||||
|
CLOSED
|
||||||
|
ARCHIVED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum StrawPollIdentityMode {
|
||||||
|
ANONYMOUS
|
||||||
|
TOKEN_GATED
|
||||||
|
AUTHENTICATED
|
||||||
|
MIXED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum StrawPollResultVisibility {
|
||||||
|
LIVE
|
||||||
|
AFTER_VOTE
|
||||||
|
AFTER_CLOSE
|
||||||
|
CREATOR_ONLY
|
||||||
|
PUBLIC_ALWAYS
|
||||||
|
}
|
||||||
|
|
||||||
|
model StrawPoll {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
slug String @unique
|
||||||
|
title String @db.VarChar(200)
|
||||||
|
description String? @db.Text
|
||||||
|
type StrawPollType
|
||||||
|
status StrawPollStatus @default(DRAFT)
|
||||||
|
identityMode StrawPollIdentityMode @default(ANONYMOUS) @map("identity_mode")
|
||||||
|
resultVisibility StrawPollResultVisibility @default(LIVE) @map("result_visibility")
|
||||||
|
allowComments Boolean @default(true) @map("allow_comments")
|
||||||
|
closesAt DateTime? @map("closes_at")
|
||||||
|
closeThreshold Int? @map("close_threshold")
|
||||||
|
autoCloseJobId String? @map("auto_close_job_id")
|
||||||
|
isPrivate Boolean @default(false) @map("is_private")
|
||||||
|
|
||||||
|
createdByUserId String @map("created_by_user_id")
|
||||||
|
createdBy User @relation("StrawPollCreator", fields: [createdByUserId], references: [id])
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
options StrawPollOption[]
|
||||||
|
votes StrawPollVote[]
|
||||||
|
comments StrawPollComment[]
|
||||||
|
challenges StrawPollChallenge[]
|
||||||
|
|
||||||
|
@@index([createdByUserId])
|
||||||
|
@@index([status])
|
||||||
|
@@map("straw_polls")
|
||||||
|
}
|
||||||
|
|
||||||
|
model StrawPollOption {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
pollId String @map("poll_id")
|
||||||
|
poll StrawPoll @relation(fields: [pollId], references: [id], onDelete: Cascade)
|
||||||
|
label String @db.VarChar(500)
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
|
||||||
|
votes StrawPollVote[]
|
||||||
|
|
||||||
|
@@index([pollId])
|
||||||
|
@@map("straw_poll_options")
|
||||||
|
}
|
||||||
|
|
||||||
|
model StrawPollVote {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
pollId String @map("poll_id")
|
||||||
|
poll StrawPoll @relation(fields: [pollId], references: [id], onDelete: Cascade)
|
||||||
|
optionId String @map("option_id")
|
||||||
|
option StrawPollOption @relation(fields: [optionId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
userId String? @map("user_id")
|
||||||
|
user User? @relation("StrawPollVoter", fields: [userId], references: [id], onDelete: SetNull)
|
||||||
|
voterName String? @db.VarChar(100) @map("voter_name")
|
||||||
|
voterToken String? @map("voter_token")
|
||||||
|
voterIp String? @map("voter_ip")
|
||||||
|
contactId String? @map("contact_id")
|
||||||
|
contact Contact? @relation("StrawPollVoteContact", fields: [contactId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@unique([pollId, userId])
|
||||||
|
@@unique([pollId, voterToken])
|
||||||
|
@@unique([pollId, voterIp])
|
||||||
|
@@index([pollId])
|
||||||
|
@@index([optionId])
|
||||||
|
@@map("straw_poll_votes")
|
||||||
|
}
|
||||||
|
|
||||||
|
model StrawPollComment {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
pollId String @map("poll_id")
|
||||||
|
poll StrawPoll @relation(fields: [pollId], references: [id], onDelete: Cascade)
|
||||||
|
userId String? @map("user_id")
|
||||||
|
user User? @relation("StrawPollCommenter", fields: [userId], references: [id], onDelete: SetNull)
|
||||||
|
authorName String @db.VarChar(100) @map("author_name")
|
||||||
|
content String @db.Text
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@index([pollId])
|
||||||
|
@@map("straw_poll_comments")
|
||||||
|
}
|
||||||
|
|
||||||
|
model StrawPollChallenge {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
pollId String @map("poll_id")
|
||||||
|
poll StrawPoll @relation(fields: [pollId], references: [id], onDelete: Cascade)
|
||||||
|
challengerUserId String @map("challenger_user_id")
|
||||||
|
challenger User @relation("StrawPollChallenger", fields: [challengerUserId], references: [id])
|
||||||
|
challengedUserId String @map("challenged_user_id")
|
||||||
|
challenged User @relation("StrawPollChallenged", fields: [challengedUserId], references: [id])
|
||||||
|
completedAt DateTime? @map("completed_at")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
@@unique([pollId, challengerUserId, challengedUserId])
|
||||||
|
@@map("straw_poll_challenges")
|
||||||
|
}
|
||||||
|
|||||||
@ -466,6 +466,32 @@ async function main() {
|
|||||||
title: 'Vote on a Meeting Time',
|
title: 'Vote on a Meeting Time',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'default-straw-poll-inline',
|
||||||
|
type: 'straw-poll-inline',
|
||||||
|
label: 'Straw Poll (Inline)',
|
||||||
|
category: 'Influence',
|
||||||
|
sortOrder: 18,
|
||||||
|
schema: {
|
||||||
|
pollSlug: { type: 'string', label: 'Poll Slug', required: true },
|
||||||
|
},
|
||||||
|
defaults: {
|
||||||
|
pollSlug: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'default-straw-poll-card',
|
||||||
|
type: 'straw-poll-card',
|
||||||
|
label: 'Straw Poll (Card)',
|
||||||
|
category: 'Influence',
|
||||||
|
sortOrder: 19,
|
||||||
|
schema: {
|
||||||
|
pollSlug: { type: 'string', label: 'Poll Slug', required: true },
|
||||||
|
},
|
||||||
|
defaults: {
|
||||||
|
pollSlug: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const block of defaultBlocks) {
|
for (const block of defaultBlocks) {
|
||||||
|
|||||||
@ -38,6 +38,11 @@ const envSchema = z.object({
|
|||||||
// Encryption (for DB-stored secrets like SMTP password — required for all environments)
|
// Encryption (for DB-stored secrets like SMTP password — required for all environments)
|
||||||
ENCRYPTION_KEY: z.string().min(32, 'ENCRYPTION_KEY must be at least 32 characters'),
|
ENCRYPTION_KEY: z.string().min(32, 'ENCRYPTION_KEY must be at least 32 characters'),
|
||||||
|
|
||||||
|
// Gitea SSO cookie signing secret (falls back to JWT_ACCESS_SECRET if empty)
|
||||||
|
GITEA_SSO_SECRET: z.string().default(''),
|
||||||
|
// Salt for deriving deterministic service passwords (Gitea, Rocket.Chat — falls back to JWT_ACCESS_SECRET if empty)
|
||||||
|
SERVICE_PASSWORD_SALT: z.string().default(''),
|
||||||
|
|
||||||
// Initial Super Admin (auto-created during database seeding)
|
// Initial Super Admin (auto-created during database seeding)
|
||||||
INITIAL_ADMIN_EMAIL: z.string().email().default('admin@cmlite.org'),
|
INITIAL_ADMIN_EMAIL: z.string().email().default('admin@cmlite.org'),
|
||||||
INITIAL_ADMIN_PASSWORD: z.string().min(12).default('REQUIRED_STRONG_PASSWORD_CHANGE_THIS')
|
INITIAL_ADMIN_PASSWORD: z.string().min(12).default('REQUIRED_STRONG_PASSWORD_CHANGE_THIS')
|
||||||
|
|||||||
@ -156,6 +156,23 @@ export const adTrackingRateLimit = rateLimit({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const smsSendRateLimit = rateLimit({
|
||||||
|
windowMs: 60 * 1000, // 1 minute
|
||||||
|
max: 10,
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
store: new RedisStore({
|
||||||
|
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
|
||||||
|
prefix: 'rl:sms-send:',
|
||||||
|
}),
|
||||||
|
message: {
|
||||||
|
error: {
|
||||||
|
message: 'Too many SMS send requests, please try again later',
|
||||||
|
code: 'SMS_SEND_RATE_LIMIT_EXCEEDED',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const quickJoinRateLimit = rateLimit({
|
export const quickJoinRateLimit = rateLimit({
|
||||||
windowMs: 60 * 60 * 1000, // 1 hour
|
windowMs: 60 * 60 * 1000, // 1 hour
|
||||||
max: 10,
|
max: 10,
|
||||||
@ -173,6 +190,23 @@ export const quickJoinRateLimit = rateLimit({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const paymentCheckoutRateLimit = rateLimit({
|
||||||
|
windowMs: 60 * 60 * 1000, // 1 hour
|
||||||
|
max: 10, // 10 checkout sessions per hour per IP
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
store: new RedisStore({
|
||||||
|
sendCommand: (command: string, ...args: string[]) => redis.call(command, ...args) as Promise<any>,
|
||||||
|
prefix: 'rl:payment-checkout:',
|
||||||
|
}),
|
||||||
|
message: {
|
||||||
|
error: {
|
||||||
|
message: 'Too many payment requests, please try again later',
|
||||||
|
code: 'PAYMENT_CHECKOUT_RATE_LIMIT_EXCEEDED',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
export const authRateLimit = rateLimit({
|
export const authRateLimit = rateLimit({
|
||||||
windowMs: 15 * 60 * 1000,
|
windowMs: 15 * 60 * 1000,
|
||||||
max: 10, // Reduced from 20 to prevent brute force attacks
|
max: 10, // Reduced from 20 to prevent brute force attacks
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { Router, Request, Response, NextFunction } from 'express';
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
|
import jwt from 'jsonwebtoken';
|
||||||
import { UserRole, UserStatus } from '@prisma/client';
|
import { UserRole, UserStatus } from '@prisma/client';
|
||||||
import { authService } from './auth.service';
|
import { authService } from './auth.service';
|
||||||
import { loginSchema, registerSchema, refreshSchema } from './auth.schemas';
|
import { loginSchema, registerSchema, refreshSchema } from './auth.schemas';
|
||||||
@ -22,6 +23,9 @@ const router = Router();
|
|||||||
const REFRESH_COOKIE_NAME = 'cml_refresh';
|
const REFRESH_COOKIE_NAME = 'cml_refresh';
|
||||||
const REFRESH_COOKIE_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days in ms
|
const REFRESH_COOKIE_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days in ms
|
||||||
|
|
||||||
|
const SESSION_COOKIE_NAME = 'cml_session';
|
||||||
|
const SESSION_COOKIE_MAX_AGE = 30 * 60 * 1000; // 30 min buffer (JWT inside enforces 15min expiry)
|
||||||
|
|
||||||
/** Set the refresh token as an httpOnly cookie.
|
/** Set the refresh token as an httpOnly cookie.
|
||||||
* Uses req.secure (respects trust proxy + X-Forwarded-Proto) to determine
|
* Uses req.secure (respects trust proxy + X-Forwarded-Proto) to determine
|
||||||
* the Secure flag, so it works correctly over both HTTP (dev) and HTTPS (tunnel). */
|
* the Secure flag, so it works correctly over both HTTP (dev) and HTTPS (tunnel). */
|
||||||
@ -45,6 +49,53 @@ function clearRefreshCookie(req: Request, res: Response) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Cookie options for the SSO session cookie (domain-wide for Gitea reverse proxy auth) */
|
||||||
|
function sessionCookieOptions(req: Request) {
|
||||||
|
const isSecure = req.secure;
|
||||||
|
const domain = env.DOMAIN;
|
||||||
|
// Use domain-wide cookie for production (subdomains); omit for localhost dev
|
||||||
|
const hasDomain = domain && !domain.includes('localhost') && !domain.match(/^\d/);
|
||||||
|
return {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: isSecure,
|
||||||
|
sameSite: 'lax' as const,
|
||||||
|
path: '/',
|
||||||
|
maxAge: SESSION_COOKIE_MAX_AGE,
|
||||||
|
...(hasDomain ? { domain: `.${domain}` } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set the SSO session cookie for Gitea reverse proxy auth (fire-and-forget).
|
||||||
|
* Only sets the cookie if the user has a provisioned Gitea account. */
|
||||||
|
async function setSessionCookie(req: Request, res: Response, userId: string) {
|
||||||
|
try {
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { permissions: true },
|
||||||
|
});
|
||||||
|
const permissions = (user?.permissions as Record<string, unknown>) || {};
|
||||||
|
const giteaUser = permissions._giteaUsername as string | undefined;
|
||||||
|
if (!giteaUser) return; // Not provisioned — skip
|
||||||
|
|
||||||
|
const ssoSecret = env.GITEA_SSO_SECRET || env.JWT_ACCESS_SECRET;
|
||||||
|
const token = jwt.sign(
|
||||||
|
{ sub: userId, giteaUser },
|
||||||
|
ssoSecret,
|
||||||
|
{ algorithm: 'HS256', expiresIn: '15m' },
|
||||||
|
);
|
||||||
|
res.cookie(SESSION_COOKIE_NAME, token, sessionCookieOptions(req));
|
||||||
|
} catch (err) {
|
||||||
|
logger.debug('Failed to set SSO session cookie:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear the SSO session cookie */
|
||||||
|
function clearSessionCookie(req: Request, res: Response) {
|
||||||
|
const opts = sessionCookieOptions(req);
|
||||||
|
delete (opts as Record<string, unknown>).maxAge;
|
||||||
|
res.clearCookie(SESSION_COOKIE_NAME, opts);
|
||||||
|
}
|
||||||
|
|
||||||
// POST /api/auth/login
|
// POST /api/auth/login
|
||||||
router.post(
|
router.post(
|
||||||
'/login',
|
'/login',
|
||||||
@ -55,6 +106,8 @@ router.post(
|
|||||||
const result = await authService.login(req.body.email, req.body.password);
|
const result = await authService.login(req.body.email, req.body.password);
|
||||||
// Set refresh token as httpOnly cookie (not in response body)
|
// Set refresh token as httpOnly cookie (not in response body)
|
||||||
setRefreshCookie(req, res, result.refreshToken);
|
setRefreshCookie(req, res, result.refreshToken);
|
||||||
|
// Set SSO session cookie for Gitea reverse proxy auth
|
||||||
|
await setSessionCookie(req, res, result.user.id);
|
||||||
const { refreshToken: _, ...responseWithoutRefresh } = result;
|
const { refreshToken: _, ...responseWithoutRefresh } = result;
|
||||||
res.json(responseWithoutRefresh);
|
res.json(responseWithoutRefresh);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -281,6 +334,10 @@ router.post(
|
|||||||
const result = await authService.refreshTokens(refreshToken);
|
const result = await authService.refreshTokens(refreshToken);
|
||||||
// Set new refresh token as httpOnly cookie
|
// Set new refresh token as httpOnly cookie
|
||||||
setRefreshCookie(req, res, result.refreshToken);
|
setRefreshCookie(req, res, result.refreshToken);
|
||||||
|
// Renew SSO session cookie for Gitea reverse proxy auth
|
||||||
|
if (result.user?.id) {
|
||||||
|
await setSessionCookie(req, res, result.user.id);
|
||||||
|
}
|
||||||
const { refreshToken: _, ...responseWithoutRefresh } = result;
|
const { refreshToken: _, ...responseWithoutRefresh } = result;
|
||||||
res.json(responseWithoutRefresh);
|
res.json(responseWithoutRefresh);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -302,9 +359,11 @@ router.post(
|
|||||||
await authService.logout(refreshToken);
|
await authService.logout(refreshToken);
|
||||||
}
|
}
|
||||||
clearRefreshCookie(req, res);
|
clearRefreshCookie(req, res);
|
||||||
|
clearSessionCookie(req, res);
|
||||||
res.json({ message: 'Logged out' });
|
res.json({ message: 'Logged out' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
clearRefreshCookie(req, res);
|
clearRefreshCookie(req, res);
|
||||||
|
clearSessionCookie(req, res);
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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: {
|
include: {
|
||||||
members: {
|
members: {
|
||||||
where: { status: SharedViewMemberStatus.ACCEPTED },
|
where: { status: SharedViewMemberStatus.ACCEPTED },
|
||||||
include: { user: { select: { id: true, name: true, email: true } } },
|
include: { user: { select: { id: true, name: true } } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -972,7 +972,7 @@ export const sharedCalendarService = {
|
|||||||
orderBy: [{ date: 'asc' }, { startTime: 'asc' }],
|
orderBy: [{ date: 'asc' }, { startTime: 'asc' }],
|
||||||
});
|
});
|
||||||
|
|
||||||
const memberName = member.user.name || member.user.email;
|
const memberName = member.user.name || 'Member';
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (item.visibility === CalendarVisibility.PRIVATE || item.visibility === CalendarVisibility.FRIENDS) continue;
|
if (item.visibility === CalendarVisibility.PRIVATE || item.visibility === CalendarVisibility.FRIENDS) continue;
|
||||||
allItems.push({
|
allItems.push({
|
||||||
@ -999,7 +999,7 @@ export const sharedCalendarService = {
|
|||||||
const sysLayers = filteredLayers.filter(l => l.layerType === CalendarLayerType.SYSTEM);
|
const sysLayers = filteredLayers.filter(l => l.layerType === CalendarLayerType.SYSTEM);
|
||||||
for (const sysLayer of sysLayers) {
|
for (const sysLayer of sysLayers) {
|
||||||
const sysItems = await this.getSystemLayerItems(member.userId, sysLayer, start, end);
|
const sysItems = await this.getSystemLayerItems(member.userId, sysLayer, start, end);
|
||||||
const memberName = member.user.name || member.user.email;
|
const memberName = member.user.name || 'Member';
|
||||||
for (const si of sysItems) {
|
for (const si of sysItems) {
|
||||||
allItems.push({
|
allItems.push({
|
||||||
...si,
|
...si,
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { Router, Request, Response, NextFunction } from 'express';
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
import { homepageService } from './homepage.service';
|
import { homepageService } from './homepage.service';
|
||||||
|
import { homepageStats } from '../../services/event-listeners/homepage-stats.listener';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@ -13,4 +14,17 @@ router.get('/', async (_req: Request, res: Response, next: NextFunction) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /api/homepage/live-stats — Real-time EventBus-driven counters (no auth)
|
||||||
|
router.get('/live-stats', async (_req: Request, res: Response, next: NextFunction) => {
|
||||||
|
try {
|
||||||
|
const counters = await homepageStats.getCounters();
|
||||||
|
const recentSignups = await homepageStats.getRecent('signups', 5);
|
||||||
|
const recentDonations = await homepageStats.getRecent('donations', 5);
|
||||||
|
const recentResponses = await homepageStats.getRecent('responses', 5);
|
||||||
|
res.json({ counters, recent: { signups: recentSignups, donations: recentDonations, responses: recentResponses } });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export { router as homepageRouter };
|
export { router as homepageRouter };
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { validate } from '../../../middleware/validate';
|
|||||||
import { authenticate } from '../../../middleware/auth.middleware';
|
import { authenticate } from '../../../middleware/auth.middleware';
|
||||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||||
import { INFLUENCE_ROLES } from '../../../utils/roles';
|
import { INFLUENCE_ROLES } from '../../../utils/roles';
|
||||||
|
import { eventBus } from '../../../services/event-bus.service';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@ -45,7 +46,22 @@ router.patch(
|
|||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const id = req.params.id as string;
|
const id = req.params.id as string;
|
||||||
|
const before = await campaignsService.findById(id);
|
||||||
const campaign = await campaignsService.moderateCampaign(id, req.body, req.user!);
|
const campaign = await campaignsService.moderateCampaign(id, req.body, req.user!);
|
||||||
|
eventBus.publish('campaign.status.changed', {
|
||||||
|
campaignId: campaign.id,
|
||||||
|
title: campaign.title,
|
||||||
|
slug: campaign.slug,
|
||||||
|
oldStatus: before.moderationStatus ?? 'PENDING',
|
||||||
|
newStatus: campaign.moderationStatus ?? 'PENDING',
|
||||||
|
});
|
||||||
|
if (campaign.status === 'ACTIVE' && before.status !== 'ACTIVE') {
|
||||||
|
eventBus.publish('campaign.published', {
|
||||||
|
campaignId: campaign.id,
|
||||||
|
title: campaign.title,
|
||||||
|
slug: campaign.slug,
|
||||||
|
});
|
||||||
|
}
|
||||||
res.json(campaign);
|
res.json(campaign);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { validate } from '../../../middleware/validate';
|
|||||||
import { authenticate } from '../../../middleware/auth.middleware';
|
import { authenticate } from '../../../middleware/auth.middleware';
|
||||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||||
import { INFLUENCE_ROLES } from '../../../utils/roles';
|
import { INFLUENCE_ROLES } from '../../../utils/roles';
|
||||||
|
import { eventBus } from '../../../services/event-bus.service';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@ -47,6 +48,12 @@ router.post(
|
|||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const campaign = await campaignsService.create(req.body, req.user!);
|
const campaign = await campaignsService.create(req.body, req.user!);
|
||||||
|
eventBus.publish('campaign.created', {
|
||||||
|
campaignId: campaign.id,
|
||||||
|
title: campaign.title,
|
||||||
|
slug: campaign.slug,
|
||||||
|
createdByUserId: campaign.createdByUserId!,
|
||||||
|
});
|
||||||
res.status(201).json(campaign);
|
res.status(201).json(campaign);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
@ -62,6 +69,12 @@ router.put(
|
|||||||
try {
|
try {
|
||||||
const id = req.params.id as string;
|
const id = req.params.id as string;
|
||||||
const campaign = await campaignsService.update(id, req.body);
|
const campaign = await campaignsService.update(id, req.body);
|
||||||
|
eventBus.publish('campaign.updated', {
|
||||||
|
campaignId: campaign.id,
|
||||||
|
title: campaign.title,
|
||||||
|
slug: campaign.slug,
|
||||||
|
changes: Object.keys(req.body),
|
||||||
|
});
|
||||||
res.json(campaign);
|
res.json(campaign);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
@ -75,7 +88,13 @@ router.delete(
|
|||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const id = req.params.id as string;
|
const id = req.params.id as string;
|
||||||
|
const campaign = await campaignsService.findById(id);
|
||||||
await campaignsService.delete(id);
|
await campaignsService.delete(id);
|
||||||
|
eventBus.publish('campaign.deleted', {
|
||||||
|
campaignId: campaign.id,
|
||||||
|
title: campaign.title,
|
||||||
|
slug: campaign.slug,
|
||||||
|
});
|
||||||
res.status(204).send();
|
res.status(204).send();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import { getAdminEmailsByRole, isNotificationEnabled } from '../../../services/n
|
|||||||
import { env } from '../../../config/env';
|
import { env } from '../../../config/env';
|
||||||
import { logger } from '../../../utils/logger';
|
import { logger } from '../../../utils/logger';
|
||||||
import { recordResponseSubmission } from '../../../utils/metrics';
|
import { recordResponseSubmission } from '../../../utils/metrics';
|
||||||
import { rocketchatWebhookService } from '../../../services/rocketchat-webhook.service';
|
import { eventBus } from '../../../services/event-bus.service';
|
||||||
import type {
|
import type {
|
||||||
SubmitResponseInput,
|
SubmitResponseInput,
|
||||||
ListPublicResponsesInput,
|
ListPublicResponsesInput,
|
||||||
@ -102,11 +102,14 @@ export const responsesService = {
|
|||||||
logger.error('Failed to enqueue response submitted notification:', err);
|
logger.error('Failed to enqueue response submitted notification:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify Rocket.Chat
|
// Publish response submitted event
|
||||||
rocketchatWebhookService.onCampaignResponseSubmitted({
|
eventBus.publish('response.submitted', {
|
||||||
|
responseId: response.id,
|
||||||
|
campaignId: campaign.id,
|
||||||
campaignTitle: campaign.title,
|
campaignTitle: campaign.title,
|
||||||
representativeName: data.representativeName,
|
representativeName: data.representativeName,
|
||||||
}).catch(() => {});
|
userEmail: data.submittedByEmail,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: response.id,
|
id: response.id,
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import { prisma } from '../../config/database';
|
|||||||
import { siteSettingsService } from '../settings/settings.service';
|
import { siteSettingsService } from '../settings/settings.service';
|
||||||
import { isServiceOnline } from '../../utils/health-check';
|
import { isServiceOnline } from '../../utils/health-check';
|
||||||
import { generateSlug, generateModeratorToken } from './jitsi.utils';
|
import { generateSlug, generateModeratorToken } from './jitsi.utils';
|
||||||
|
import { eventBus } from '../../services/event-bus.service';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@ -172,6 +173,14 @@ router.post(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
eventBus.publish('meeting.created', {
|
||||||
|
meetingId: meeting.id,
|
||||||
|
title: meeting.title,
|
||||||
|
scheduledAt: meeting.startTime?.toISOString() ?? new Date().toISOString(),
|
||||||
|
jitsiRoomName: meeting.jitsiRoom,
|
||||||
|
createdByUserId: meeting.createdByUserId,
|
||||||
|
});
|
||||||
|
|
||||||
res.status(201).json(meeting);
|
res.status(201).json(meeting);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Create meeting failed:', err);
|
logger.error('Create meeting failed:', err);
|
||||||
@ -226,6 +235,12 @@ router.delete(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await prisma.meeting.delete({ where: { id: meetingId } });
|
await prisma.meeting.delete({ where: { id: meetingId } });
|
||||||
|
|
||||||
|
eventBus.publish('meeting.deleted', {
|
||||||
|
meetingId: meeting.id,
|
||||||
|
title: meeting.title,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('Delete meeting failed:', err);
|
logger.error('Delete meeting failed:', err);
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { Router, Request, Response, NextFunction } from 'express';
|
|||||||
import { prisma } from '../../config/database';
|
import { prisma } from '../../config/database';
|
||||||
import { env } from '../../config/env';
|
import { env } from '../../config/env';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
|
import { eventBus } from '../../services/event-bus.service';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@ -32,6 +33,13 @@ router.post(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Publish unsubscribe event to EventBus
|
||||||
|
eventBus.publish('listmonk.unsubscribed', {
|
||||||
|
subscriberEmail: email,
|
||||||
|
listId: event?.data?.list?.id ?? 0,
|
||||||
|
listName: event?.data?.list?.name ?? '',
|
||||||
|
});
|
||||||
|
|
||||||
// Store opt-out flag in user's permissions JSON field
|
// Store opt-out flag in user's permissions JSON field
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { email },
|
where: { email },
|
||||||
@ -58,6 +66,56 @@ router.post(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Email open event
|
||||||
|
if (eventType === 'campaign.view') {
|
||||||
|
const email = event?.data?.subscriber?.email;
|
||||||
|
const campaignId = event?.data?.campaign?.id;
|
||||||
|
const campaignName = event?.data?.campaign?.name;
|
||||||
|
if (email && campaignId) {
|
||||||
|
eventBus.publish('listmonk.email.opened', {
|
||||||
|
subscriberEmail: email,
|
||||||
|
campaignId,
|
||||||
|
campaignName: campaignName ?? '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.json({ ok: true, action: 'published', eventType });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link click event
|
||||||
|
if (eventType === 'campaign.link_click') {
|
||||||
|
const email = event?.data?.subscriber?.email;
|
||||||
|
const campaignId = event?.data?.campaign?.id;
|
||||||
|
const campaignName = event?.data?.campaign?.name;
|
||||||
|
const url = event?.data?.url;
|
||||||
|
if (email && campaignId) {
|
||||||
|
eventBus.publish('listmonk.email.clicked', {
|
||||||
|
subscriberEmail: email,
|
||||||
|
campaignId,
|
||||||
|
campaignName: campaignName ?? '',
|
||||||
|
url: url ?? '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.json({ ok: true, action: 'published', eventType });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bounce event
|
||||||
|
if (eventType === 'subscriber.bounced') {
|
||||||
|
const email = event?.data?.subscriber?.email;
|
||||||
|
const campaignId = event?.data?.campaign?.id;
|
||||||
|
const bounceType = event?.data?.bounce_type ?? 'unknown';
|
||||||
|
if (email) {
|
||||||
|
eventBus.publish('listmonk.email.bounced', {
|
||||||
|
subscriberEmail: email,
|
||||||
|
campaignId: campaignId ?? 0,
|
||||||
|
bounceType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.json({ ok: true, action: 'published', eventType });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Unknown event type — acknowledge but don't process
|
// Unknown event type — acknowledge but don't process
|
||||||
logger.debug(`Listmonk webhook: unhandled event type "${eventType}"`);
|
logger.debug(`Listmonk webhook: unhandled event type "${eventType}"`);
|
||||||
res.json({ ok: true, action: 'ignored', eventType });
|
res.json({ ok: true, action: 'ignored', eventType });
|
||||||
|
|||||||
@ -11,8 +11,7 @@ import { recordCanvassVisit, setActiveCanvassSessions } from '../../../utils/met
|
|||||||
import { notificationQueueService } from '../../../services/notification-queue.service';
|
import { notificationQueueService } from '../../../services/notification-queue.service';
|
||||||
import { getAdminEmailsByRole, isNotificationEnabled } from '../../../services/notification.helper';
|
import { getAdminEmailsByRole, isNotificationEnabled } from '../../../services/notification.helper';
|
||||||
import { env } from '../../../config/env';
|
import { env } from '../../../config/env';
|
||||||
import { rocketchatWebhookService } from '../../../services/rocketchat-webhook.service';
|
import { eventBus } from '../../../services/event-bus.service';
|
||||||
import { listmonkEventSyncService } from '../../../services/listmonk-event-sync.service';
|
|
||||||
import { achievementsService } from '../../social/achievements.service';
|
import { achievementsService } from '../../social/achievements.service';
|
||||||
import type {
|
import type {
|
||||||
RecordVisitInput,
|
RecordVisitInput,
|
||||||
@ -254,20 +253,6 @@ export const canvassService = {
|
|||||||
// Recalculate cut completion percentage
|
// Recalculate cut completion percentage
|
||||||
await this.recalculateCutCompletion(session.cutId);
|
await this.recalculateCutCompletion(session.cutId);
|
||||||
|
|
||||||
// Notify Rocket.Chat
|
|
||||||
try {
|
|
||||||
const [rcUser, rcCut, rcVisitCount] = await Promise.all([
|
|
||||||
prisma.user.findUnique({ where: { id: userId }, select: { name: true, email: true } }),
|
|
||||||
prisma.cut.findUnique({ where: { id: session.cutId }, select: { name: true } }),
|
|
||||||
prisma.canvassVisit.count({ where: { sessionId } }),
|
|
||||||
]);
|
|
||||||
rocketchatWebhookService.onCanvassSessionCompleted({
|
|
||||||
userName: rcUser?.name || rcUser?.email || 'Unknown',
|
|
||||||
visitCount: rcVisitCount,
|
|
||||||
cutName: rcCut?.name || undefined,
|
|
||||||
}).catch(() => {});
|
|
||||||
} catch { /* non-critical */ }
|
|
||||||
|
|
||||||
// Notification: volunteer session summary
|
// Notification: volunteer session summary
|
||||||
try {
|
try {
|
||||||
if (await isNotificationEnabled('notifyVolunteerSessionSummary')) {
|
if (await isNotificationEnabled('notifyVolunteerSessionSummary')) {
|
||||||
@ -315,7 +300,7 @@ export const canvassService = {
|
|||||||
logger.error('Failed to enqueue session summary notification:', err);
|
logger.error('Failed to enqueue session summary notification:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listmonk event sync — add canvasser to subscribers
|
// Publish canvass session completed event (consumed by RC, Listmonk, etc.)
|
||||||
try {
|
try {
|
||||||
const [syncUser, syncCut, syncVisitCount, syncOutcomes] = await Promise.all([
|
const [syncUser, syncCut, syncVisitCount, syncOutcomes] = await Promise.all([
|
||||||
prisma.user.findUnique({ where: { id: userId }, select: { email: true, name: true } }),
|
prisma.user.findUnique({ where: { id: userId }, select: { email: true, name: true } }),
|
||||||
@ -328,13 +313,15 @@ export const canvassService = {
|
|||||||
for (const row of syncOutcomes) {
|
for (const row of syncOutcomes) {
|
||||||
outcomes[row.outcome] = row._count;
|
outcomes[row.outcome] = row._count;
|
||||||
}
|
}
|
||||||
listmonkEventSyncService.onCanvassSessionCompleted({
|
eventBus.publish('canvass.session.completed', {
|
||||||
email: syncUser.email,
|
sessionId,
|
||||||
name: syncUser.name || syncUser.email,
|
userId,
|
||||||
|
userName: syncUser.name || syncUser.email,
|
||||||
|
userEmail: syncUser.email,
|
||||||
cutName: syncCut?.name || 'Unknown',
|
cutName: syncCut?.name || 'Unknown',
|
||||||
visitCount: syncVisitCount,
|
visitCount: syncVisitCount,
|
||||||
outcomes,
|
outcomes,
|
||||||
}).catch(() => {});
|
});
|
||||||
}
|
}
|
||||||
} catch { /* non-critical */ }
|
} catch { /* non-critical */ }
|
||||||
|
|
||||||
@ -650,16 +637,16 @@ export const canvassService = {
|
|||||||
include: { location: { select: { address: true } } },
|
include: { location: { select: { address: true } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sync support level change to Listmonk (fire-and-forget)
|
// Publish address updated event (consumed by Listmonk, etc.)
|
||||||
if (updatedAddress.email) {
|
if (updatedAddress.email) {
|
||||||
const name = [updatedAddress.firstName, updatedAddress.lastName].filter(Boolean).join(' ');
|
const name = [updatedAddress.firstName, updatedAddress.lastName].filter(Boolean).join(' ');
|
||||||
listmonkEventSyncService.onAddressUpdated({
|
eventBus.publish('contact.address.updated', {
|
||||||
email: updatedAddress.email,
|
email: updatedAddress.email,
|
||||||
name,
|
name,
|
||||||
supportLevel: updatedAddress.supportLevel,
|
supportLevel: updatedAddress.supportLevel,
|
||||||
sign: updatedAddress.sign,
|
sign: updatedAddress.sign,
|
||||||
address: updatedAddress.location.address,
|
address: updatedAddress.location.address,
|
||||||
}).catch(() => {});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,9 +8,7 @@ import { getAdminEmailsByRole, isNotificationEnabled } from '../../../services/n
|
|||||||
import { env } from '../../../config/env';
|
import { env } from '../../../config/env';
|
||||||
import { logger } from '../../../utils/logger';
|
import { logger } from '../../../utils/logger';
|
||||||
import { recordShiftSignup } from '../../../utils/metrics';
|
import { recordShiftSignup } from '../../../utils/metrics';
|
||||||
import { rocketchatWebhookService } from '../../../services/rocketchat-webhook.service';
|
import { eventBus } from '../../../services/event-bus.service';
|
||||||
import { listmonkEventSyncService } from '../../../services/listmonk-event-sync.service';
|
|
||||||
import { gancioClient } from '../../../services/gancio.client';
|
|
||||||
import { unifiedCalendarService } from '../../events/unified-calendar.service';
|
import { unifiedCalendarService } from '../../events/unified-calendar.service';
|
||||||
import { groupService } from '../../social/group.service';
|
import { groupService } from '../../social/group.service';
|
||||||
import { achievementsService } from '../../social/achievements.service';
|
import { achievementsService } from '../../social/achievements.service';
|
||||||
@ -138,26 +136,17 @@ export const shiftsService = {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Gancio event sync (fire-and-forget)
|
// Publish shift.created event (listeners: Gancio, Calendar, n8n)
|
||||||
if (gancioClient.enabled) {
|
eventBus.publish('shift.created', {
|
||||||
gancioClient.createEvent({
|
shiftId: shift.id,
|
||||||
title: shift.title,
|
title: shift.title,
|
||||||
description: shift.description,
|
date: new Date(shift.date).toISOString().split('T')[0],
|
||||||
location: shift.location,
|
|
||||||
date: shift.date,
|
|
||||||
startTime: shift.startTime,
|
startTime: shift.startTime,
|
||||||
endTime: shift.endTime,
|
endTime: shift.endTime,
|
||||||
}).then(async (eventId) => {
|
cutId: shift.cutId,
|
||||||
if (eventId) {
|
cutName: null,
|
||||||
await prisma.shift.update({
|
createdByUserId: userId,
|
||||||
where: { id: shift.id },
|
|
||||||
data: { gancioEventId: eventId },
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
}).catch((err) => {
|
|
||||||
logger.warn('Gancio sync on shift create failed:', err);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bust unified calendar cache
|
// Bust unified calendar cache
|
||||||
unifiedCalendarService.bustCache().catch(() => {});
|
unifiedCalendarService.bustCache().catch(() => {});
|
||||||
@ -191,19 +180,17 @@ export const shiftsService = {
|
|||||||
data: updateData,
|
data: updateData,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Gancio event sync (fire-and-forget)
|
// Publish shift.updated event (listeners: Gancio, Calendar, n8n)
|
||||||
if (gancioClient.enabled && shift.gancioEventId) {
|
eventBus.publish('shift.updated', {
|
||||||
gancioClient.updateEvent(shift.gancioEventId, {
|
shiftId: shift.id,
|
||||||
title: shift.title,
|
title: shift.title,
|
||||||
description: shift.description,
|
date: new Date(shift.date).toISOString().split('T')[0],
|
||||||
location: shift.location,
|
|
||||||
date: shift.date,
|
|
||||||
startTime: shift.startTime,
|
startTime: shift.startTime,
|
||||||
endTime: shift.endTime,
|
endTime: shift.endTime,
|
||||||
}).catch((err) => {
|
cutId: shift.cutId,
|
||||||
logger.warn('Gancio sync on shift update failed:', err);
|
cutName: null,
|
||||||
|
changes: Object.keys(data),
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// Bust unified calendar cache
|
// Bust unified calendar cache
|
||||||
unifiedCalendarService.bustCache().catch(() => {});
|
unifiedCalendarService.bustCache().catch(() => {});
|
||||||
@ -217,12 +204,12 @@ export const shiftsService = {
|
|||||||
throw new AppError(404, 'Shift not found', 'SHIFT_NOT_FOUND');
|
throw new AppError(404, 'Shift not found', 'SHIFT_NOT_FOUND');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete Gancio event before deleting shift (fire-and-forget)
|
// Publish shift.deleted event (listeners: Gancio, Calendar, n8n)
|
||||||
if (gancioClient.enabled && existing.gancioEventId) {
|
eventBus.publish('shift.deleted', {
|
||||||
gancioClient.deleteEvent(existing.gancioEventId).catch((err) => {
|
shiftId: id,
|
||||||
logger.warn('Gancio sync on shift delete failed:', err);
|
title: existing.title,
|
||||||
|
date: new Date(existing.date).toISOString().split('T')[0],
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// Delete associated meeting if exists
|
// Delete associated meeting if exists
|
||||||
if (existing.meetingId) {
|
if (existing.meetingId) {
|
||||||
@ -359,13 +346,17 @@ export const shiftsService = {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Listmonk event sync
|
// Publish shift.signup.created event (listeners: Listmonk, RC, CRM, n8n)
|
||||||
listmonkEventSyncService.onShiftSignup({
|
eventBus.publish('shift.signup.created', {
|
||||||
email: data.userEmail,
|
shiftId,
|
||||||
name: data.userName || data.userEmail,
|
|
||||||
shiftTitle: shift.title,
|
shiftTitle: shift.title,
|
||||||
shiftDate: new Date(shift.date).toISOString().split('T')[0],
|
shiftDate: new Date(shift.date).toISOString().split('T')[0],
|
||||||
}).catch(() => {});
|
userName: data.userName || data.userEmail,
|
||||||
|
userEmail: data.userEmail,
|
||||||
|
userId: user?.id ?? null,
|
||||||
|
cutName: null,
|
||||||
|
signupType: 'admin',
|
||||||
|
});
|
||||||
|
|
||||||
// Social group sync (fire-and-forget)
|
// Social group sync (fire-and-forget)
|
||||||
groupService.syncShiftTeam(shiftId).catch(() => {});
|
groupService.syncShiftTeam(shiftId).catch(() => {});
|
||||||
@ -551,14 +542,6 @@ export const shiftsService = {
|
|||||||
}).catch(err => logger.error('SMS signup confirmation failed:', err));
|
}).catch(err => logger.error('SMS signup confirmation failed:', err));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify Rocket.Chat
|
|
||||||
const shiftDateStr = new Date(shift.date).toLocaleDateString('en-CA', { month: 'short', day: 'numeric' });
|
|
||||||
rocketchatWebhookService.onShiftSignup({
|
|
||||||
userName: data.name || data.email,
|
|
||||||
shiftTitle: shift.title,
|
|
||||||
shiftDate: shiftDateStr,
|
|
||||||
}).catch(() => {});
|
|
||||||
|
|
||||||
// Notification: admin shift signup alert
|
// Notification: admin shift signup alert
|
||||||
try {
|
try {
|
||||||
if (await isNotificationEnabled('notifyAdminShiftSignup')) {
|
if (await isNotificationEnabled('notifyAdminShiftSignup')) {
|
||||||
@ -651,13 +634,17 @@ export const shiftsService = {
|
|||||||
|
|
||||||
recordShiftSignup();
|
recordShiftSignup();
|
||||||
|
|
||||||
// Listmonk event sync
|
// Publish shift.signup.created event (listeners: Listmonk, RC, CRM, n8n)
|
||||||
listmonkEventSyncService.onShiftSignup({
|
eventBus.publish('shift.signup.created', {
|
||||||
email: data.email,
|
shiftId,
|
||||||
name: data.name,
|
|
||||||
shiftTitle: shift.title,
|
shiftTitle: shift.title,
|
||||||
shiftDate: new Date(shift.date).toISOString().split('T')[0],
|
shiftDate: new Date(shift.date).toISOString().split('T')[0],
|
||||||
}).catch(() => {});
|
userName: data.name || data.email,
|
||||||
|
userEmail: data.email,
|
||||||
|
userId: user?.id ?? null,
|
||||||
|
cutName: null,
|
||||||
|
signupType: 'public',
|
||||||
|
});
|
||||||
|
|
||||||
// Social group sync (fire-and-forget)
|
// Social group sync (fire-and-forget)
|
||||||
groupService.syncShiftTeam(shiftId).catch(() => {});
|
groupService.syncShiftTeam(shiftId).catch(() => {});
|
||||||
@ -733,14 +720,16 @@ export const shiftsService = {
|
|||||||
logger.error('Failed to enqueue cancellation notification:', err);
|
logger.error('Failed to enqueue cancellation notification:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify Rocket.Chat of cancellation
|
// Publish shift.signup.cancelled event (listeners: RC, n8n)
|
||||||
if (shift) {
|
if (shift) {
|
||||||
const shiftDateStr = new Date(shift.date).toLocaleDateString('en-CA', { month: 'short', day: 'numeric' });
|
eventBus.publish('shift.signup.cancelled', {
|
||||||
rocketchatWebhookService.onShiftCancellation({
|
shiftId,
|
||||||
userName: signup.userName || userEmail,
|
|
||||||
shiftTitle: shift.title,
|
shiftTitle: shift.title,
|
||||||
shiftDate: shiftDateStr,
|
shiftDate: new Date(shift.date).toISOString().split('T')[0],
|
||||||
}).catch(() => {});
|
userName: signup.userName || userEmail,
|
||||||
|
userEmail,
|
||||||
|
signupType: 'public',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notification: admin shift cancellation alert
|
// Notification: admin shift cancellation alert
|
||||||
@ -896,14 +885,6 @@ export const shiftsService = {
|
|||||||
logger.error('Failed to send volunteer shift signup confirmation email:', err);
|
logger.error('Failed to send volunteer shift signup confirmation email:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify Rocket.Chat
|
|
||||||
const shiftDateStr = new Date(shift.date).toLocaleDateString('en-CA', { month: 'short', day: 'numeric' });
|
|
||||||
rocketchatWebhookService.onShiftSignup({
|
|
||||||
userName: user.name || user.email,
|
|
||||||
shiftTitle: shift.title,
|
|
||||||
shiftDate: shiftDateStr,
|
|
||||||
}).catch(() => {});
|
|
||||||
|
|
||||||
// Notification: admin shift signup alert
|
// Notification: admin shift signup alert
|
||||||
try {
|
try {
|
||||||
if (await isNotificationEnabled('notifyAdminShiftSignup')) {
|
if (await isNotificationEnabled('notifyAdminShiftSignup')) {
|
||||||
@ -980,13 +961,17 @@ export const shiftsService = {
|
|||||||
logger.error('Failed to schedule shift thank-you:', err);
|
logger.error('Failed to schedule shift thank-you:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listmonk event sync
|
// Publish shift.signup.created event (listeners: Listmonk, RC, CRM, n8n)
|
||||||
listmonkEventSyncService.onShiftSignup({
|
eventBus.publish('shift.signup.created', {
|
||||||
email: user.email,
|
shiftId,
|
||||||
name: user.name || user.email,
|
|
||||||
shiftTitle: shift.title,
|
shiftTitle: shift.title,
|
||||||
shiftDate: new Date(shift.date).toISOString().split('T')[0],
|
shiftDate: new Date(shift.date).toISOString().split('T')[0],
|
||||||
}).catch(() => {});
|
userName: user.name || user.email,
|
||||||
|
userEmail: user.email,
|
||||||
|
userId,
|
||||||
|
cutName: null,
|
||||||
|
signupType: 'volunteer',
|
||||||
|
});
|
||||||
|
|
||||||
// Social group sync (fire-and-forget)
|
// Social group sync (fire-and-forget)
|
||||||
groupService.syncShiftTeam(shiftId).catch(() => {});
|
groupService.syncShiftTeam(shiftId).catch(() => {});
|
||||||
@ -1060,14 +1045,16 @@ export const shiftsService = {
|
|||||||
logger.error('Failed to enqueue cancellation notification:', err);
|
logger.error('Failed to enqueue cancellation notification:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify Rocket.Chat of cancellation
|
// Publish shift.signup.cancelled event (listeners: RC, n8n)
|
||||||
if (shift) {
|
if (shift) {
|
||||||
const shiftDateStr = new Date(shift.date).toLocaleDateString('en-CA', { month: 'short', day: 'numeric' });
|
eventBus.publish('shift.signup.cancelled', {
|
||||||
rocketchatWebhookService.onShiftCancellation({
|
shiftId,
|
||||||
userName: user.name || user.email,
|
|
||||||
shiftTitle: shift.title,
|
shiftTitle: shift.title,
|
||||||
shiftDate: shiftDateStr,
|
shiftDate: new Date(shift.date).toISOString().split('T')[0],
|
||||||
}).catch(() => {});
|
userName: user.name || user.email,
|
||||||
|
userEmail: user.email,
|
||||||
|
signupType: 'volunteer',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notification: admin shift cancellation alert
|
// Notification: admin shift cancellation alert
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { FastifyInstance } from 'fastify';
|
import { FastifyInstance } from 'fastify';
|
||||||
import { createReadStream } from 'fs';
|
import { createReadStream } from 'fs';
|
||||||
import { access } from 'fs/promises';
|
import { access } from 'fs/promises';
|
||||||
|
import { resolve } from 'path';
|
||||||
import { prisma } from '../../../config/database';
|
import { prisma } from '../../../config/database';
|
||||||
import { optionalAuth } from '../middleware/auth';
|
import { optionalAuth } from '../middleware/auth';
|
||||||
import { logger } from '../../../utils/logger';
|
import { logger } from '../../../utils/logger';
|
||||||
@ -181,19 +182,25 @@ export async function photosPublicRoutes(fastify: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!filePath || filePath.includes('..')) {
|
if (!filePath) {
|
||||||
return reply.code(404).send({ message: 'Image variant not found' });
|
return reply.code(404).send({ message: 'Image variant not found' });
|
||||||
}
|
}
|
||||||
|
const PHOTOS_BASE = '/media/local/photos';
|
||||||
|
const resolvedPath = resolve(filePath);
|
||||||
|
if (!resolvedPath.startsWith(resolve(PHOTOS_BASE) + '/')) {
|
||||||
|
logger.warn(`Photo path traversal attempt blocked: ${filePath}`);
|
||||||
|
return reply.code(403).send({ message: 'Access denied' });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await access(filePath);
|
await access(resolvedPath);
|
||||||
} catch {
|
} catch {
|
||||||
return reply.code(404).send({ message: 'Image file not found' });
|
return reply.code(404).send({ message: 'Image file not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
reply.header('Content-Type', contentType);
|
reply.header('Content-Type', contentType);
|
||||||
reply.header('Cache-Control', 'public, max-age=604800, immutable');
|
reply.header('Cache-Control', 'public, max-age=604800, immutable');
|
||||||
return reply.send(createReadStream(filePath));
|
return reply.send(createReadStream(resolvedPath));
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -208,19 +215,25 @@ export async function photosPublicRoutes(fastify: FastifyInstance) {
|
|||||||
select: { thumbnailPath: true },
|
select: { thumbnailPath: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!photo?.thumbnailPath || photo.thumbnailPath.includes('..')) {
|
if (!photo?.thumbnailPath) {
|
||||||
return reply.code(404).send({ message: 'Thumbnail not found' });
|
return reply.code(404).send({ message: 'Thumbnail not found' });
|
||||||
}
|
}
|
||||||
|
const PHOTOS_BASE = '/media/local/photos';
|
||||||
|
const resolvedThumb = resolve(photo.thumbnailPath);
|
||||||
|
if (!resolvedThumb.startsWith(resolve(PHOTOS_BASE) + '/')) {
|
||||||
|
logger.warn(`Thumbnail path traversal attempt blocked: ${photo.thumbnailPath}`);
|
||||||
|
return reply.code(403).send({ message: 'Access denied' });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await access(photo.thumbnailPath);
|
await access(resolvedThumb);
|
||||||
} catch {
|
} catch {
|
||||||
return reply.code(404).send({ message: 'Thumbnail file not found' });
|
return reply.code(404).send({ message: 'Thumbnail file not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
reply.header('Content-Type', 'image/jpeg');
|
reply.header('Content-Type', 'image/jpeg');
|
||||||
reply.header('Cache-Control', 'public, max-age=604800, immutable');
|
reply.header('Cache-Control', 'public, max-age=604800, immutable');
|
||||||
return reply.send(createReadStream(photo.thumbnailPath));
|
return reply.send(createReadStream(resolvedThumb));
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { optionalAuth } from '../middleware/auth';
|
|||||||
import { videoAnalyticsService } from '../services/video-analytics.service';
|
import { videoAnalyticsService } from '../services/video-analytics.service';
|
||||||
import { logger } from '../../../utils/logger';
|
import { logger } from '../../../utils/logger';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { eventBus } from '../../../services/event-bus.service';
|
||||||
|
|
||||||
// Validation schemas
|
// Validation schemas
|
||||||
const recordViewSchema = z.object({
|
const recordViewSchema = z.object({
|
||||||
@ -62,6 +63,13 @@ export async function videoTrackingRoutes(fastify: FastifyInstance) {
|
|||||||
referer,
|
referer,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
eventBus.publish('media.video.viewed', {
|
||||||
|
videoId: String(videoId),
|
||||||
|
videoTitle: '', // Title not available in tracking context
|
||||||
|
userId: userId ?? null,
|
||||||
|
sessionId: String(viewId),
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
viewId,
|
viewId,
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { join } from 'path';
|
|||||||
import { extractVideoMetadata } from '../services/ffprobe.service';
|
import { extractVideoMetadata } from '../services/ffprobe.service';
|
||||||
import { ThumbnailService } from '../services/thumbnail.service';
|
import { ThumbnailService } from '../services/thumbnail.service';
|
||||||
import { logger } from '../../../utils/logger';
|
import { logger } from '../../../utils/logger';
|
||||||
|
import { eventBus } from '../../../services/event-bus.service';
|
||||||
|
|
||||||
// List videos endpoint (admin only for now)
|
// List videos endpoint (admin only for now)
|
||||||
interface ListVideosQuery {
|
interface ListVideosQuery {
|
||||||
@ -206,7 +207,15 @@ export async function videosRoutes(fastify: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const userId = (request as any).user?.id || 'unknown';
|
||||||
logger.info(`Video ${videoId} published to ${category}`);
|
logger.info(`Video ${videoId} published to ${category}`);
|
||||||
|
|
||||||
|
eventBus.publish('media.video.published', {
|
||||||
|
videoId: String(videoId),
|
||||||
|
title: video.title || video.filename || `Video #${videoId}`,
|
||||||
|
publishedByUserId: userId,
|
||||||
|
});
|
||||||
|
|
||||||
return { success: true, video };
|
return { success: true, video };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error(`Error publishing video ${videoId}:`, error);
|
logger.error(`Error publishing video ${videoId}:`, error);
|
||||||
@ -233,6 +242,12 @@ export async function videosRoutes(fastify: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
logger.info(`Video ${videoId} unpublished`);
|
logger.info(`Video ${videoId} unpublished`);
|
||||||
|
|
||||||
|
eventBus.publish('media.video.unpublished', {
|
||||||
|
videoId: String(videoId),
|
||||||
|
title: video.title || video.filename || `Video #${videoId}`,
|
||||||
|
});
|
||||||
|
|
||||||
return { success: true, video };
|
return { success: true, video };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error(`Error unpublishing video ${videoId}:`, error);
|
logger.error(`Error unpublishing video ${videoId}:`, error);
|
||||||
|
|||||||
@ -8,7 +8,8 @@ import {
|
|||||||
import { validate } from '../../middleware/validate';
|
import { validate } from '../../middleware/validate';
|
||||||
import { authenticate } from '../../middleware/auth.middleware';
|
import { authenticate } from '../../middleware/auth.middleware';
|
||||||
import { requireRole } from '../../middleware/rbac.middleware';
|
import { requireRole } from '../../middleware/rbac.middleware';
|
||||||
import { EVENTS_ROLES } from '../../utils/roles';
|
import { EVENTS_ROLES, hasAnyRole } from '../../utils/roles';
|
||||||
|
import { AppError } from '../../middleware/error-handler';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@ -44,6 +45,14 @@ router.get('/:id', authenticate, async (req: Request, res: Response, next: NextF
|
|||||||
try {
|
try {
|
||||||
const id = req.params.id as string;
|
const id = req.params.id as string;
|
||||||
const item = await actionItemsService.findById(id);
|
const item = await actionItemsService.findById(id);
|
||||||
|
|
||||||
|
const isAdmin = hasAnyRole(req.user!, EVENTS_ROLES);
|
||||||
|
const isAssignee = item.assigneeUserId === req.user!.id;
|
||||||
|
const isCreator = item.createdByUserId === req.user!.id;
|
||||||
|
if (!isAdmin && !isAssignee && !isCreator) {
|
||||||
|
throw new AppError(403, 'Insufficient permissions', 'FORBIDDEN');
|
||||||
|
}
|
||||||
|
|
||||||
res.json(item);
|
res.json(item);
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
@ -56,10 +65,19 @@ router.post('/', authenticate, requireRole(...EVENTS_ROLES), validate(createActi
|
|||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update action item (authenticate only - assignees can update their own)
|
// Update action item — admins, assignees, or creators can update
|
||||||
router.put('/:id', authenticate, validate(updateActionItemSchema), async (req: Request, res: Response, next: NextFunction) => {
|
router.put('/:id', authenticate, validate(updateActionItemSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const id = req.params.id as string;
|
const id = req.params.id as string;
|
||||||
|
const existing = await actionItemsService.findById(id);
|
||||||
|
|
||||||
|
const isAdmin = hasAnyRole(req.user!, EVENTS_ROLES);
|
||||||
|
const isAssignee = existing.assigneeUserId === req.user!.id;
|
||||||
|
const isCreator = existing.createdByUserId === req.user!.id;
|
||||||
|
if (!isAdmin && !isAssignee && !isCreator) {
|
||||||
|
throw new AppError(403, 'Insufficient permissions', 'FORBIDDEN');
|
||||||
|
}
|
||||||
|
|
||||||
const item = await actionItemsService.update(id, req.body);
|
const item = await actionItemsService.update(id, req.body);
|
||||||
res.json(item);
|
res.json(item);
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
|
|||||||
@ -221,4 +221,24 @@ router.get(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// GET /api/observability/event-bus — EventBus stats
|
||||||
|
router.get(
|
||||||
|
'/event-bus',
|
||||||
|
async (_req: Request, res: Response) => {
|
||||||
|
const { eventBus } = await import('../../services/event-bus.service');
|
||||||
|
res.json(eventBus.getStats());
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /api/observability/engagement-leaderboard — Top engaged contacts
|
||||||
|
router.get(
|
||||||
|
'/engagement-leaderboard',
|
||||||
|
async (req: Request, res: Response) => {
|
||||||
|
const { engagementScoring } = await import('../../services/event-listeners/engagement-scoring.listener');
|
||||||
|
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
|
||||||
|
const leaderboard = await engagementScoring.getLeaderboard(limit);
|
||||||
|
res.json({ leaderboard, count: leaderboard.length });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export const observabilityRouter = router;
|
export const observabilityRouter = router;
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { Router, Request, Response, NextFunction } from 'express';
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
import { validate } from '../../middleware/validate';
|
import { validate } from '../../middleware/validate';
|
||||||
|
import { paymentCheckoutRateLimit } from '../../middleware/rate-limit';
|
||||||
|
import { requirePaymentsEnabled } from './payment-settings.service';
|
||||||
import { donationPagesService } from './donation-pages.service';
|
import { donationPagesService } from './donation-pages.service';
|
||||||
import { donationsService } from './donations.service';
|
import { donationsService } from './donations.service';
|
||||||
import { donationPageCheckoutSchema } from './donation-pages.schemas';
|
import { donationPageCheckoutSchema } from './donation-pages.schemas';
|
||||||
@ -29,6 +31,8 @@ router.get('/:slug', async (req: Request, res: Response, next: NextFunction) =>
|
|||||||
// POST /api/donation-pages/:slug/donate — create Stripe checkout for this page
|
// POST /api/donation-pages/:slug/donate — create Stripe checkout for this page
|
||||||
router.post(
|
router.post(
|
||||||
'/:slug/donate',
|
'/:slug/donate',
|
||||||
|
requirePaymentsEnabled,
|
||||||
|
paymentCheckoutRateLimit,
|
||||||
validate(donationPageCheckoutSchema),
|
validate(donationPageCheckoutSchema),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -31,7 +31,7 @@ export const listDonationPagesSchema = z.object({
|
|||||||
export type ListDonationPagesInput = z.infer<typeof listDonationPagesSchema>;
|
export type ListDonationPagesInput = z.infer<typeof listDonationPagesSchema>;
|
||||||
|
|
||||||
export const donationPageCheckoutSchema = z.object({
|
export const donationPageCheckoutSchema = z.object({
|
||||||
amountCents: z.number().int().min(100),
|
amountCents: z.number().int().min(100).max(10000000), // max $100,000
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
name: z.string().max(200).optional(),
|
name: z.string().max(200).optional(),
|
||||||
message: z.string().max(2000).optional(),
|
message: z.string().max(2000).optional(),
|
||||||
|
|||||||
@ -141,10 +141,18 @@ export const donationsService = {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const updated = await prisma.order.update({
|
// 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 },
|
where: { id: orderId },
|
||||||
data: { status: 'REFUNDED' },
|
data: { status: 'REFUNDED' },
|
||||||
});
|
});
|
||||||
|
} catch (dbErr) {
|
||||||
|
logger.error(`Stripe refund succeeded but DB update failed for order ${orderId}. Webhook will reconcile.`, dbErr);
|
||||||
|
throw new Error('Refund processed by Stripe but local status update failed. It will be reconciled shortly.');
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(`Donation refunded: ${orderId}, $${(order.amountCAD / 100).toFixed(2)}`, {
|
logger.info(`Donation refunded: ${orderId}, $${(order.amountCAD / 100).toFixed(2)}`, {
|
||||||
orderId,
|
orderId,
|
||||||
@ -187,8 +195,6 @@ export const donationsService = {
|
|||||||
'Donation Page': sanitizeCsvValue(o.donationPage?.title || 'General'),
|
'Donation Page': sanitizeCsvValue(o.donationPage?.title || 'General'),
|
||||||
'Message': sanitizeCsvValue(o.donorMessage || ''),
|
'Message': sanitizeCsvValue(o.donorMessage || ''),
|
||||||
'Anonymous': o.isAnonymous ? 'Yes' : 'No',
|
'Anonymous': o.isAnonymous ? 'Yes' : 'No',
|
||||||
'Stripe Payment Intent': o.stripePaymentIntentId || '',
|
|
||||||
'Stripe Checkout Session': o.stripeCheckoutSessionId || '',
|
|
||||||
'Completed At': o.completedAt ? o.completedAt.toISOString() : '',
|
'Completed At': o.completedAt ? o.completedAt.toISOString() : '',
|
||||||
'Order ID': o.id,
|
'Order ID': o.id,
|
||||||
})), { header: true });
|
})), { header: true });
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
import { prisma } from '../../config/database';
|
import { prisma } from '../../config/database';
|
||||||
import type { PaymentSettings } from '@prisma/client';
|
import type { PaymentSettings } from '@prisma/client';
|
||||||
import type { UpdatePaymentSettingsInput } from './payments.schemas';
|
import type { UpdatePaymentSettingsInput } from './payments.schemas';
|
||||||
@ -40,10 +41,16 @@ export const paymentSettingsService = {
|
|||||||
async update(data: UpdatePaymentSettingsInput): Promise<PaymentSettings> {
|
async update(data: UpdatePaymentSettingsInput): Promise<PaymentSettings> {
|
||||||
const toWrite = { ...data } as Record<string, unknown>;
|
const toWrite = { ...data } as Record<string, unknown>;
|
||||||
|
|
||||||
// Encrypt sensitive fields
|
// Encrypt sensitive fields, skipping masked sentinel values from the admin UI
|
||||||
for (const field of ENCRYPTED_FIELDS) {
|
for (const field of ENCRYPTED_FIELDS) {
|
||||||
if (field in toWrite && typeof toWrite[field] === 'string' && toWrite[field]) {
|
if (field in toWrite && typeof toWrite[field] === 'string') {
|
||||||
toWrite[field] = encrypt(toWrite[field] as string);
|
const val = toWrite[field] as string;
|
||||||
|
if (!val || val.startsWith('••••')) {
|
||||||
|
// Empty or mask string submitted — preserve existing encrypted value
|
||||||
|
delete toWrite[field];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
toWrite[field] = encrypt(val);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -69,3 +76,17 @@ export const paymentSettingsService = {
|
|||||||
return decryptSettings(settings);
|
return decryptSettings(settings);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Middleware: reject requests when payments are disabled in site settings */
|
||||||
|
export async function requirePaymentsEnabled(_req: Request, res: Response, next: NextFunction) {
|
||||||
|
try {
|
||||||
|
const settings = await prisma.siteSettings.findFirst({ select: { enablePayments: true } });
|
||||||
|
if (!settings?.enablePayments) {
|
||||||
|
res.status(403).json({ error: { message: 'Payments are not enabled', code: 'PAYMENTS_DISABLED' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -37,6 +37,7 @@ router.get('/settings', async (_req: Request, res: Response, next: NextFunction)
|
|||||||
stripeSecretKey: settings.stripeSecretKey ? '••••' + settings.stripeSecretKey.slice(-4) : '',
|
stripeSecretKey: settings.stripeSecretKey ? '••••' + settings.stripeSecretKey.slice(-4) : '',
|
||||||
stripeWebhookSecret: settings.stripeWebhookSecret ? '••••' + settings.stripeWebhookSecret.slice(-4) : '',
|
stripeWebhookSecret: settings.stripeWebhookSecret ? '••••' + settings.stripeWebhookSecret.slice(-4) : '',
|
||||||
};
|
};
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
res.json(masked);
|
res.json(masked);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
@ -50,7 +51,14 @@ router.put(
|
|||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const settings = await paymentSettingsService.update(req.body);
|
const settings = await paymentSettingsService.update(req.body);
|
||||||
res.json(settings);
|
// Mask secrets in response (same as GET) to prevent leaking decrypted keys
|
||||||
|
const masked = {
|
||||||
|
...settings,
|
||||||
|
stripeSecretKey: settings.stripeSecretKey ? '••••' + settings.stripeSecretKey.slice(-4) : '',
|
||||||
|
stripeWebhookSecret: settings.stripeWebhookSecret ? '••••' + settings.stripeWebhookSecret.slice(-4) : '',
|
||||||
|
};
|
||||||
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
|
res.json(masked);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
import { Router, Request, Response, NextFunction } from 'express';
|
import { Router, Request, Response, NextFunction } from 'express';
|
||||||
import { getPublishableKey } from '../../services/stripe.client';
|
import { getPublishableKey } from '../../services/stripe.client';
|
||||||
import { paymentSettingsService } from './payment-settings.service';
|
import { paymentSettingsService, requirePaymentsEnabled } from './payment-settings.service';
|
||||||
import { subscriptionsService } from './subscriptions.service';
|
import { subscriptionsService } from './subscriptions.service';
|
||||||
import { plansService } from './plans.service';
|
import { plansService } from './plans.service';
|
||||||
import { productsService } from './products.service';
|
import { productsService } from './products.service';
|
||||||
import { donationsService } from './donations.service';
|
import { donationsService } from './donations.service';
|
||||||
import { authenticate } from '../../middleware/auth.middleware';
|
import { authenticate } from '../../middleware/auth.middleware';
|
||||||
import { validate } from '../../middleware/validate';
|
import { validate } from '../../middleware/validate';
|
||||||
|
import { paymentCheckoutRateLimit } from '../../middleware/rate-limit';
|
||||||
import {
|
import {
|
||||||
createSubscriptionCheckoutSchema,
|
createSubscriptionCheckoutSchema,
|
||||||
createProductCheckoutSchema,
|
createProductCheckoutSchema,
|
||||||
@ -85,6 +86,8 @@ router.get('/products/:slug', async (req: Request, res: Response, next: NextFunc
|
|||||||
// POST /api/payments/subscribe — create subscription checkout (requires login)
|
// POST /api/payments/subscribe — create subscription checkout (requires login)
|
||||||
router.post(
|
router.post(
|
||||||
'/subscribe',
|
'/subscribe',
|
||||||
|
requirePaymentsEnabled,
|
||||||
|
paymentCheckoutRateLimit,
|
||||||
authenticate,
|
authenticate,
|
||||||
validate(createSubscriptionCheckoutSchema),
|
validate(createSubscriptionCheckoutSchema),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
@ -105,6 +108,8 @@ router.post(
|
|||||||
// POST /api/payments/purchase — create product checkout (guest or logged-in)
|
// POST /api/payments/purchase — create product checkout (guest or logged-in)
|
||||||
router.post(
|
router.post(
|
||||||
'/purchase',
|
'/purchase',
|
||||||
|
requirePaymentsEnabled,
|
||||||
|
paymentCheckoutRateLimit,
|
||||||
validate(createProductCheckoutSchema),
|
validate(createProductCheckoutSchema),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
@ -122,6 +127,8 @@ router.post(
|
|||||||
// POST /api/payments/donate — create donation checkout (no auth required)
|
// POST /api/payments/donate — create donation checkout (no auth required)
|
||||||
router.post(
|
router.post(
|
||||||
'/donate',
|
'/donate',
|
||||||
|
requirePaymentsEnabled,
|
||||||
|
paymentCheckoutRateLimit,
|
||||||
validate(createDonationCheckoutSchema),
|
validate(createDonationCheckoutSchema),
|
||||||
async (req: Request, res: Response, next: NextFunction) => {
|
async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -84,7 +84,7 @@ export const createProductCheckoutSchema = z.object({
|
|||||||
// --- Donation ---
|
// --- Donation ---
|
||||||
|
|
||||||
export const createDonationCheckoutSchema = z.object({
|
export const createDonationCheckoutSchema = z.object({
|
||||||
amountCents: z.number().int().min(100),
|
amountCents: z.number().int().min(100).max(10000000), // max $100,000
|
||||||
email: z.string().email(),
|
email: z.string().email(),
|
||||||
name: z.string().max(200).optional(),
|
name: z.string().max(200).optional(),
|
||||||
message: z.string().max(2000).optional(),
|
message: z.string().max(2000).optional(),
|
||||||
@ -111,7 +111,7 @@ export const subscriptionFiltersSchema = z.object({
|
|||||||
export const orderFiltersSchema = z.object({
|
export const orderFiltersSchema = z.object({
|
||||||
page: z.coerce.number().int().min(1).default(1),
|
page: z.coerce.number().int().min(1).default(1),
|
||||||
limit: z.coerce.number().int().min(1).max(100).default(20),
|
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||||
status: z.enum(['PENDING', 'COMPLETED', 'FAILED', 'REFUNDED']).optional(),
|
status: z.enum(['PENDING', 'COMPLETED', 'FAILED', 'REFUNDED', 'DISPUTED']).optional(),
|
||||||
type: z.enum(['product', 'donation']).optional(),
|
type: z.enum(['product', 'donation']).optional(),
|
||||||
search: z.string().optional(),
|
search: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -228,12 +228,16 @@ export const productsService = {
|
|||||||
/** Create Stripe Checkout for a product purchase */
|
/** Create Stripe Checkout for a product purchase */
|
||||||
async createProductCheckout(productId: string, buyerEmail: string, buyerName?: string, userId?: string) {
|
async createProductCheckout(productId: string, buyerEmail: string, buyerName?: string, userId?: string) {
|
||||||
const stripe = await getStripe();
|
const stripe = await getStripe();
|
||||||
const product = await prisma.product.findUnique({ where: { id: productId } });
|
|
||||||
if (!product || !product.isActive) throw new Error('Product not found or inactive');
|
|
||||||
|
|
||||||
if (product.maxPurchases && product.purchaseCount >= product.maxPurchases) {
|
// Atomic availability check to prevent overselling under concurrency
|
||||||
|
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');
|
throw new Error('Product is sold out');
|
||||||
}
|
}
|
||||||
|
return p;
|
||||||
|
});
|
||||||
|
|
||||||
const session = await stripe.checkout.sessions.create({
|
const session = await stripe.checkout.sessions.create({
|
||||||
mode: 'payment',
|
mode: 'payment',
|
||||||
@ -367,9 +371,16 @@ export const productsService = {
|
|||||||
await stripe.refunds.create({ payment_intent: order.stripePaymentIntentId });
|
await stripe.refunds.create({ payment_intent: order.stripePaymentIntentId });
|
||||||
}
|
}
|
||||||
|
|
||||||
return prisma.order.update({
|
// Stripe refund succeeded — update DB. If this fails, the charge.refunded
|
||||||
|
// webhook will reconcile the status as a fallback.
|
||||||
|
try {
|
||||||
|
return await prisma.order.update({
|
||||||
where: { id: orderId },
|
where: { id: orderId },
|
||||||
data: { status: 'REFUNDED' },
|
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() : '',
|
'Current Period End': s.currentPeriodEnd ? s.currentPeriodEnd.toISOString() : '',
|
||||||
'Cancel at Period End': s.cancelAtPeriodEnd ? 'Yes' : 'No',
|
'Cancel at Period End': s.cancelAtPeriodEnd ? 'Yes' : 'No',
|
||||||
'Cancelled At': s.cancelledAt ? s.cancelledAt.toISOString() : '',
|
'Cancelled At': s.cancelledAt ? s.cancelledAt.toISOString() : '',
|
||||||
'Stripe Subscription ID': s.stripeSubscriptionId || '',
|
|
||||||
'Stripe Customer ID': s.stripeCustomerId || '',
|
|
||||||
'Subscription ID': s.id.toString(),
|
'Subscription ID': s.id.toString(),
|
||||||
'User ID': s.userId,
|
'User ID': s.userId,
|
||||||
})), { header: true });
|
})), { header: true });
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { getStripe, getWebhookSecret } from '../../services/stripe.client';
|
|||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
import { recordCrmActivity } from '../../utils/crm-activity';
|
import { recordCrmActivity } from '../../utils/crm-activity';
|
||||||
import { paymentEmailService } from './payment-email.service';
|
import { paymentEmailService } from './payment-email.service';
|
||||||
import { listmonkEventSyncService } from '../../services/listmonk-event-sync.service';
|
import { eventBus } from '../../services/event-bus.service';
|
||||||
|
|
||||||
// Helper to extract subscription ID from invoice (may be string, object, or missing in newer types)
|
// Helper to extract subscription ID from invoice (may be string, object, or missing in newer types)
|
||||||
function getSubscriptionId(invoice: Stripe.Invoice): string | null {
|
function getSubscriptionId(invoice: Stripe.Invoice): string | null {
|
||||||
@ -48,6 +48,12 @@ export const webhookService = {
|
|||||||
case 'charge.refunded':
|
case 'charge.refunded':
|
||||||
await this.handleChargeRefunded(event.data.object as Stripe.Charge);
|
await this.handleChargeRefunded(event.data.object as Stripe.Charge);
|
||||||
break;
|
break;
|
||||||
|
case 'charge.dispute.created':
|
||||||
|
await this.handleDisputeCreated(event.data.object as Stripe.Dispute);
|
||||||
|
break;
|
||||||
|
case 'charge.dispute.closed':
|
||||||
|
await this.handleDisputeClosed(event.data.object as Stripe.Dispute);
|
||||||
|
break;
|
||||||
case 'checkout.session.expired':
|
case 'checkout.session.expired':
|
||||||
await this.handleCheckoutExpired(event.data.object as Stripe.Checkout.Session);
|
await this.handleCheckoutExpired(event.data.object as Stripe.Checkout.Session);
|
||||||
break;
|
break;
|
||||||
@ -142,12 +148,12 @@ export const webhookService = {
|
|||||||
const subUser = await prisma.user.findUnique({ where: { id: userId }, select: { email: true, name: true } });
|
const subUser = await prisma.user.findUnique({ where: { id: userId }, select: { email: true, name: true } });
|
||||||
const plan = await prisma.subscriptionPlan.findUnique({ where: { id: parseInt(planId, 10) }, select: { name: true } });
|
const plan = await prisma.subscriptionPlan.findUnique({ where: { id: parseInt(planId, 10) }, select: { name: true } });
|
||||||
if (subUser) {
|
if (subUser) {
|
||||||
listmonkEventSyncService.onSubscriptionActivated({
|
eventBus.publish('payment.subscription.activated', {
|
||||||
email: subUser.email,
|
email: subUser.email,
|
||||||
name: subUser.name || '',
|
name: subUser.name || '',
|
||||||
planName: plan?.name || `Plan ${planId}`,
|
planName: plan?.name || `Plan ${planId}`,
|
||||||
subscriptionId,
|
subscriptionId,
|
||||||
}).catch(() => {});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -207,13 +213,13 @@ export const webhookService = {
|
|||||||
|
|
||||||
// Sync to Listmonk Donors list (fire-and-forget)
|
// Sync to Listmonk Donors list (fire-and-forget)
|
||||||
if (updatedOrder.buyerEmail) {
|
if (updatedOrder.buyerEmail) {
|
||||||
listmonkEventSyncService.onProductPurchased({
|
eventBus.publish('payment.product.purchased', {
|
||||||
email: updatedOrder.buyerEmail,
|
email: updatedOrder.buyerEmail,
|
||||||
name: updatedOrder.buyerName || '',
|
name: updatedOrder.buyerName || '',
|
||||||
productTitle: updatedOrder.product?.title || 'Product',
|
productTitle: updatedOrder.product?.title || 'Product',
|
||||||
amountCents: updatedOrder.amountCAD,
|
amountCents: updatedOrder.amountCAD,
|
||||||
orderId: updatedOrder.id,
|
orderId: updatedOrder.id,
|
||||||
}).catch(() => {});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// CRM activity (fire-and-forget)
|
// CRM activity (fire-and-forget)
|
||||||
@ -282,12 +288,12 @@ export const webhookService = {
|
|||||||
|
|
||||||
// Sync to Listmonk Donors list (fire-and-forget)
|
// Sync to Listmonk Donors list (fire-and-forget)
|
||||||
if (order.buyerEmail) {
|
if (order.buyerEmail) {
|
||||||
listmonkEventSyncService.onDonationCompleted({
|
eventBus.publish('payment.donation.completed', {
|
||||||
email: order.buyerEmail,
|
email: order.buyerEmail,
|
||||||
name: order.buyerName || '',
|
name: order.buyerName || '',
|
||||||
amountCents: order.amountCAD,
|
amountCents: order.amountCAD,
|
||||||
orderId: order.id,
|
orderId: order.id,
|
||||||
}).catch(() => {});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// CRM activity (fire-and-forget)
|
// CRM activity (fire-and-forget)
|
||||||
@ -518,17 +524,103 @@ export const webhookService = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Check payments
|
async handleDisputeCreated(dispute: Stripe.Dispute) {
|
||||||
const payment = await prisma.payment.findFirst({
|
const paymentIntentId = typeof dispute.payment_intent === 'string'
|
||||||
|
? dispute.payment_intent
|
||||||
|
: (dispute.payment_intent as { id: string } | null)?.id;
|
||||||
|
if (!paymentIntentId) return;
|
||||||
|
|
||||||
|
const order = await prisma.order.findFirst({
|
||||||
where: { stripePaymentIntentId: paymentIntentId },
|
where: { stripePaymentIntentId: paymentIntentId },
|
||||||
});
|
});
|
||||||
if (payment && payment.status !== 'refunded') {
|
if (!order || order.status === 'DISPUTED') return;
|
||||||
await prisma.payment.update({
|
|
||||||
where: { id: payment.id },
|
const previousStatus = order.status;
|
||||||
data: { status: 'refunded' },
|
await prisma.order.update({
|
||||||
|
where: { id: order.id },
|
||||||
|
data: { status: 'DISPUTED' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Invalidate event tickets if applicable
|
||||||
|
if (order.type === 'event_ticket') {
|
||||||
|
const tickets = await prisma.ticket.findMany({
|
||||||
|
where: { orderId: order.id, status: 'VALID' },
|
||||||
|
});
|
||||||
|
for (const ticket of tickets) {
|
||||||
|
await prisma.ticket.update({
|
||||||
|
where: { id: ticket.id },
|
||||||
|
data: { status: 'CANCELLED' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (tickets.length > 0) {
|
||||||
|
logger.info(`Invalidated ${tickets.length} tickets for disputed order ${order.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.createAuditLog('dispute_created', {
|
||||||
|
orderId: order.id,
|
||||||
|
previousStatus,
|
||||||
|
disputeId: dispute.id,
|
||||||
|
reason: dispute.reason,
|
||||||
|
amount: dispute.amount,
|
||||||
|
});
|
||||||
|
logger.warn(`Dispute created for order ${order.id}: ${dispute.reason} ($${(dispute.amount / 100).toFixed(2)})`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async handleDisputeClosed(dispute: Stripe.Dispute) {
|
||||||
|
const paymentIntentId = typeof dispute.payment_intent === 'string'
|
||||||
|
? dispute.payment_intent
|
||||||
|
: (dispute.payment_intent as { id: string } | null)?.id;
|
||||||
|
if (!paymentIntentId) return;
|
||||||
|
|
||||||
|
const order = await prisma.order.findFirst({
|
||||||
|
where: { stripePaymentIntentId: paymentIntentId },
|
||||||
|
});
|
||||||
|
if (!order) return;
|
||||||
|
|
||||||
|
// Stripe types don't include closed dispute statuses (won/lost/charge_refunded)
|
||||||
|
const disputeStatus = dispute.status as string;
|
||||||
|
|
||||||
|
// If dispute was won (resolved in our favor), restore the order + tickets
|
||||||
|
if (disputeStatus === 'won') {
|
||||||
|
await prisma.order.update({
|
||||||
|
where: { id: order.id },
|
||||||
|
data: { status: 'COMPLETED' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restore tickets that were cancelled when the dispute was opened
|
||||||
|
if (order.type === 'event_ticket') {
|
||||||
|
await prisma.ticket.updateMany({
|
||||||
|
where: { orderId: order.id, status: 'CANCELLED' },
|
||||||
|
data: { status: 'VALID' },
|
||||||
|
});
|
||||||
|
logger.info(`Restored tickets for dispute-won order ${order.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Dispute won for order ${order.id}, restored to COMPLETED`);
|
||||||
|
} else if (disputeStatus === 'lost') {
|
||||||
|
// Dispute lost — funds returned to customer
|
||||||
|
await prisma.order.update({
|
||||||
|
where: { id: order.id },
|
||||||
|
data: { status: 'REFUNDED' },
|
||||||
|
});
|
||||||
|
logger.warn(`Dispute lost for order ${order.id}, marked REFUNDED`);
|
||||||
|
} else if (disputeStatus === 'charge_refunded') {
|
||||||
|
// Merchant refunded while dispute was in flight — dispute auto-closed
|
||||||
|
await prisma.order.update({
|
||||||
|
where: { id: order.id },
|
||||||
|
data: { status: 'REFUNDED' },
|
||||||
|
});
|
||||||
|
logger.info(`Dispute closed via refund for order ${order.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.createAuditLog('dispute_closed', {
|
||||||
|
orderId: order.id,
|
||||||
|
disputeId: dispute.id,
|
||||||
|
outcome: dispute.status,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
async handleCheckoutExpired(session: Stripe.Checkout.Session) {
|
async handleCheckoutExpired(session: Stripe.Checkout.Session) {
|
||||||
@ -562,8 +654,19 @@ export const webhookService = {
|
|||||||
|
|
||||||
async createAuditLog(action: string, metadata: Record<string, unknown>) {
|
async createAuditLog(action: string, metadata: Record<string, unknown>) {
|
||||||
try {
|
try {
|
||||||
|
const orderId = typeof metadata.orderId === 'string' ? metadata.orderId : undefined;
|
||||||
|
const userId = typeof metadata.userId === 'string' ? metadata.userId : undefined;
|
||||||
|
await prisma.paymentAuditLog.create({
|
||||||
|
data: {
|
||||||
|
action,
|
||||||
|
orderId: orderId || null,
|
||||||
|
userId: userId || null,
|
||||||
|
metadata: metadata as import('@prisma/client').Prisma.InputJsonValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
logger.info(`Payment audit: ${action}`, metadata);
|
logger.info(`Payment audit: ${action}`, metadata);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
// Audit log failure must not break payment processing
|
||||||
logger.error('Failed to create audit log', err);
|
logger.error('Failed to create audit log', err);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { prisma } from '../../config/database';
|
|||||||
import { redis } from '../../config/redis';
|
import { redis } from '../../config/redis';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
import { AppError } from '../../middleware/error-handler';
|
import { AppError } from '../../middleware/error-handler';
|
||||||
|
import { eventBus } from '../../services/event-bus.service';
|
||||||
import type { Prisma } from '@prisma/client';
|
import type { Prisma } from '@prisma/client';
|
||||||
import type {
|
import type {
|
||||||
ListPeopleInput,
|
ListPeopleInput,
|
||||||
@ -1053,13 +1054,12 @@ export const peopleService = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (input.email) {
|
if (input.email) {
|
||||||
import('../../services/listmonk-event-sync.service').then(({ listmonkEventSyncService }) => {
|
eventBus.publish('contact.tags.changed', {
|
||||||
listmonkEventSyncService.onContactTagsChanged({
|
|
||||||
email: input.email!,
|
email: input.email!,
|
||||||
name: contact.displayName || '',
|
name: contact.displayName || '',
|
||||||
|
contactId: contact.id,
|
||||||
addedTags: initialTags,
|
addedTags: initialTags,
|
||||||
removedTags: [],
|
removedTags: [],
|
||||||
}).catch(err => logger.debug('Listmonk tag sync failed on create:', err));
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1133,13 +1133,12 @@ export const peopleService = {
|
|||||||
|
|
||||||
const email = (data.email !== undefined ? (data.email === '' ? null : data.email) : existing.email);
|
const email = (data.email !== undefined ? (data.email === '' ? null : data.email) : existing.email);
|
||||||
if (email) {
|
if (email) {
|
||||||
import('../../services/listmonk-event-sync.service').then(({ listmonkEventSyncService }) => {
|
eventBus.publish('contact.tags.changed', {
|
||||||
listmonkEventSyncService.onContactTagsChanged({
|
|
||||||
email,
|
email,
|
||||||
name: contact.displayName || '',
|
name: contact.displayName || '',
|
||||||
|
contactId: existing.id,
|
||||||
addedTags,
|
addedTags,
|
||||||
removedTags,
|
removedTags,
|
||||||
}).catch(err => logger.debug('Listmonk tag sync failed:', err));
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1358,13 +1357,12 @@ export const peopleService = {
|
|||||||
|
|
||||||
const mergedEmail = target.email || sourceContact?.email;
|
const mergedEmail = target.email || sourceContact?.email;
|
||||||
if (mergedEmail) {
|
if (mergedEmail) {
|
||||||
import('../../services/listmonk-event-sync.service').then(({ listmonkEventSyncService }) => {
|
eventBus.publish('contact.tags.changed', {
|
||||||
listmonkEventSyncService.onContactTagsChanged({
|
|
||||||
email: mergedEmail,
|
email: mergedEmail,
|
||||||
name: target.displayName,
|
name: target.displayName,
|
||||||
|
contactId: targetId,
|
||||||
addedTags: addedToTarget,
|
addedTags: addedToTarget,
|
||||||
removedTags: [],
|
removedTags: [],
|
||||||
}).catch(err => logger.debug('Listmonk tag sync failed on merge:', err));
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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(),
|
enableMeet: z.boolean().optional(),
|
||||||
enableMeetingPlanner: z.boolean().optional(),
|
enableMeetingPlanner: z.boolean().optional(),
|
||||||
enableTicketedEvents: z.boolean().optional(),
|
enableTicketedEvents: z.boolean().optional(),
|
||||||
|
enablePolls: z.boolean().optional(),
|
||||||
enableSocialCalendar: z.boolean().optional(),
|
enableSocialCalendar: z.boolean().optional(),
|
||||||
enableDocsCollaboration: z.boolean().optional(),
|
enableDocsCollaboration: z.boolean().optional(),
|
||||||
requireEventApproval: z.boolean().optional(),
|
requireEventApproval: z.boolean().optional(),
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { validate } from '../../../middleware/validate';
|
|||||||
import { smsCampaignsService } from './sms-campaigns.service';
|
import { smsCampaignsService } from './sms-campaigns.service';
|
||||||
import { createSmsCampaignSchema, updateSmsCampaignSchema } from './sms-campaigns.schemas';
|
import { createSmsCampaignSchema, updateSmsCampaignSchema } from './sms-campaigns.schemas';
|
||||||
import { smsQueueService } from '../../../services/sms-queue.service';
|
import { smsQueueService } from '../../../services/sms-queue.service';
|
||||||
|
import { eventBus } from '../../../services/event-bus.service';
|
||||||
import { BROADCAST_ROLES } from '../../../utils/roles';
|
import { BROADCAST_ROLES } from '../../../utils/roles';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@ -66,7 +67,17 @@ router.delete('/:id', async (req, res, next) => {
|
|||||||
// POST /api/sms/campaigns/:id/start — start sending
|
// POST /api/sms/campaigns/:id/start — start sending
|
||||||
router.post('/:id/start', async (req, res, next) => {
|
router.post('/:id/start', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
const campaign = await smsCampaignsService.findById(req.params.id as string);
|
||||||
const result = await smsCampaignsService.start(req.params.id as string);
|
const result = await smsCampaignsService.start(req.params.id as string);
|
||||||
|
|
||||||
|
if (campaign) {
|
||||||
|
eventBus.publish('sms.campaign.started', {
|
||||||
|
campaignId: campaign.id,
|
||||||
|
title: campaign.name,
|
||||||
|
recipientCount: campaign.totalRecipients,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|||||||
@ -171,6 +171,11 @@ router.post('/:id/import-csv', async (req, res, next) => {
|
|||||||
res.status(400).json({ error: 'CSV text is required in the "csv" field' });
|
res.status(400).json({ error: 'CSV text is required in the "csv" field' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const MAX_CSV_SIZE = 5 * 1024 * 1024; // 5MB
|
||||||
|
if (csv.length > MAX_CSV_SIZE) {
|
||||||
|
res.status(400).json({ error: { message: 'CSV too large (max 5MB)', code: 'CSV_TOO_LARGE' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
const result = await smsContactsService.importCsv(req.params.id as string, csv, filename);
|
const result = await smsContactsService.importCsv(req.params.id as string, csv, filename);
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
|
|||||||
@ -1,9 +1,14 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { authenticate } from '../../../middleware/auth.middleware';
|
import { authenticate } from '../../../middleware/auth.middleware';
|
||||||
import { requireRole } from '../../../middleware/rbac.middleware';
|
import { requireRole } from '../../../middleware/rbac.middleware';
|
||||||
|
import { smsSendRateLimit } from '../../../middleware/rate-limit';
|
||||||
import { smsMessagesService } from './sms-messages.service';
|
import { smsMessagesService } from './sms-messages.service';
|
||||||
|
import { eventBus } from '../../../services/event-bus.service';
|
||||||
import { BROADCAST_ROLES } from '../../../utils/roles';
|
import { BROADCAST_ROLES } from '../../../utils/roles';
|
||||||
|
|
||||||
|
const MAX_SMS_LENGTH = 1600;
|
||||||
|
const PHONE_DIGITS_RE = /^\d{10,11}$/;
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
router.use(authenticate, requireRole(...BROADCAST_ROLES));
|
router.use(authenticate, requireRole(...BROADCAST_ROLES));
|
||||||
@ -32,14 +37,30 @@ router.get('/followups', async (_req, res, next) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// POST /api/sms/messages/send — send ad-hoc SMS
|
// POST /api/sms/messages/send — send ad-hoc SMS
|
||||||
router.post('/send', async (req, res, next) => {
|
router.post('/send', smsSendRateLimit, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const { phone, message } = req.body as { phone?: string; message?: string };
|
const { phone, message } = req.body as { phone?: string; message?: string };
|
||||||
if (!phone || !message) {
|
if (!phone || !message) {
|
||||||
res.status(400).json({ error: 'Phone and message are required' });
|
res.status(400).json({ error: { message: 'Phone and message are required', code: 'VALIDATION_ERROR' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const digits = phone.replace(/\D/g, '');
|
||||||
|
if (!PHONE_DIGITS_RE.test(digits)) {
|
||||||
|
res.status(400).json({ error: { message: 'Invalid phone number format', code: 'VALIDATION_ERROR' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (message.length > MAX_SMS_LENGTH) {
|
||||||
|
res.status(400).json({ error: { message: `Message too long (max ${MAX_SMS_LENGTH} characters)`, code: 'VALIDATION_ERROR' } });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const result = await smsMessagesService.sendSingle(phone, message);
|
const result = await smsMessagesService.sendSingle(phone, message);
|
||||||
|
|
||||||
|
eventBus.publish('sms.message.sent', {
|
||||||
|
messageId: result.id,
|
||||||
|
phone: result.phone,
|
||||||
|
body: result.message,
|
||||||
|
});
|
||||||
|
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { friendshipService } from './friendship.service';
|
|||||||
/** A unified feed item representing any activity type */
|
/** A unified feed item representing any activity type */
|
||||||
export interface FeedItem {
|
export interface FeedItem {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'shift_signup' | 'campaign_email' | 'canvass_session' | 'response_submitted' | 'impact_story' | 'volunteer_featured' | 'referral_completed' | 'challenge_completed';
|
type: 'shift_signup' | 'campaign_email' | 'canvass_session' | 'response_submitted' | 'impact_story' | 'volunteer_featured' | 'referral_completed' | 'challenge_completed' | 'poll_voted';
|
||||||
userId: string;
|
userId: string;
|
||||||
userName: string | null;
|
userName: string | null;
|
||||||
userEmail: string;
|
userEmail: string;
|
||||||
@ -56,7 +56,7 @@ export const feedService = {
|
|||||||
since.setDate(since.getDate() - FEED_MAX_AGE_DAYS);
|
since.setDate(since.getDate() - FEED_MAX_AGE_DAYS);
|
||||||
|
|
||||||
// Query all activity types in parallel
|
// Query all activity types in parallel
|
||||||
const [shiftSignups, campaignEmails, canvassSessions, responses, impactStories, spotlights, referrals, challenges] = await Promise.all([
|
const [shiftSignups, campaignEmails, canvassSessions, responses, impactStories, spotlights, referrals, challenges, pollVotes] = await Promise.all([
|
||||||
this.getShiftSignupActivities(visibleFriendIds, since),
|
this.getShiftSignupActivities(visibleFriendIds, since),
|
||||||
this.getCampaignEmailActivities(visibleFriendIds, since),
|
this.getCampaignEmailActivities(visibleFriendIds, since),
|
||||||
this.getCanvassSessionActivities(visibleFriendIds, since),
|
this.getCanvassSessionActivities(visibleFriendIds, since),
|
||||||
@ -65,6 +65,7 @@ export const feedService = {
|
|||||||
this.getSpotlightActivities(since),
|
this.getSpotlightActivities(since),
|
||||||
this.getReferralActivities(visibleFriendIds, since),
|
this.getReferralActivities(visibleFriendIds, since),
|
||||||
this.getChallengeActivities(since),
|
this.getChallengeActivities(since),
|
||||||
|
this.getStrawPollVoteActivities(visibleFriendIds, since),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Merge and sort by timestamp descending
|
// Merge and sort by timestamp descending
|
||||||
@ -77,6 +78,7 @@ export const feedService = {
|
|||||||
...spotlights,
|
...spotlights,
|
||||||
...referrals,
|
...referrals,
|
||||||
...challenges,
|
...challenges,
|
||||||
|
...pollVotes,
|
||||||
].sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
|
].sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
|
||||||
|
|
||||||
// Cap total items
|
// Cap total items
|
||||||
@ -362,4 +364,34 @@ export const feedService = {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getStrawPollVoteActivities(userIds: string[], since: Date): Promise<FeedItem[]> {
|
||||||
|
const votes = await prisma.strawPollVote.findMany({
|
||||||
|
where: {
|
||||||
|
userId: { in: userIds },
|
||||||
|
createdAt: { gte: since },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true, email: true } },
|
||||||
|
poll: { select: { id: true, slug: true, title: true } },
|
||||||
|
option: { select: { label: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
take: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
return votes
|
||||||
|
.filter((v) => v.user)
|
||||||
|
.map((v) => ({
|
||||||
|
id: `poll_vote:${v.id}`,
|
||||||
|
type: 'poll_voted' as const,
|
||||||
|
userId: v.user!.id,
|
||||||
|
userName: v.user!.name,
|
||||||
|
userEmail: v.user!.email,
|
||||||
|
title: `Voted on "${v.poll.title}"`,
|
||||||
|
description: `Chose: ${v.option.label}`,
|
||||||
|
metadata: { pollId: v.poll.id, pollSlug: v.poll.slug },
|
||||||
|
timestamp: v.createdAt,
|
||||||
|
}));
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import { requireRole } from '../../middleware/rbac.middleware';
|
|||||||
import { INFLUENCE_ROLES } from '../../utils/roles';
|
import { INFLUENCE_ROLES } from '../../utils/roles';
|
||||||
import { impactStoriesService } from './impact-stories.service';
|
import { impactStoriesService } from './impact-stories.service';
|
||||||
import { createStorySchema, updateStorySchema, listStoriesSchema } from './impact-stories.schemas';
|
import { createStorySchema, updateStorySchema, listStoriesSchema } from './impact-stories.schemas';
|
||||||
|
import { eventBus } from '../../services/event-bus.service';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@ -42,6 +43,14 @@ router.post('/:id/publish', requireRole(...INFLUENCE_ROLES), async (req, res, ne
|
|||||||
const story = await impactStoriesService.publish(req.params.id as string);
|
const story = await impactStoriesService.publish(req.params.id as string);
|
||||||
// Fire-and-forget: notify participants
|
// Fire-and-forget: notify participants
|
||||||
impactStoriesService.notifyParticipants(story.id).catch(() => {});
|
impactStoriesService.notifyParticipants(story.id).catch(() => {});
|
||||||
|
|
||||||
|
eventBus.publish('social.impact-story.published', {
|
||||||
|
storyId: story.id,
|
||||||
|
title: story.title,
|
||||||
|
authorUserId: story.createdByUserId || req.user!.id,
|
||||||
|
campaignId: story.campaignId ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
res.json(story);
|
res.json(story);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
|
|||||||
@ -31,6 +31,18 @@ router.get('/campaigns/:campaignId/friends', async (req: Request, res: Response)
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** GET /api/social/integration/straw-polls/:pollId/friends */
|
||||||
|
router.get('/straw-polls/:pollId/friends', async (req: Request, res: Response) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user!.id;
|
||||||
|
const pollId = req.params.pollId as string;
|
||||||
|
const result = await integrationService.getFriendsInStrawPoll(userId, pollId);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err: any) {
|
||||||
|
res.status(err.statusCode || 500).json({ error: { message: err.message } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/** GET /api/social/integration/map/friends */
|
/** GET /api/social/integration/map/friends */
|
||||||
router.get('/map/friends', async (req: Request, res: Response) => {
|
router.get('/map/friends', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -99,6 +99,38 @@ export const integrationService = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/** Get friends who voted on a straw poll */
|
||||||
|
async getFriendsInStrawPoll(userId: string, pollId: string) {
|
||||||
|
const friendIds = await friendshipService.getFriendIds(userId);
|
||||||
|
if (friendIds.length === 0) return { friends: [], count: 0 };
|
||||||
|
|
||||||
|
const hiddenIds = await this.getHiddenActivityUserIds(friendIds);
|
||||||
|
const visibleIds = friendIds.filter((id) => !hiddenIds.has(id));
|
||||||
|
if (visibleIds.length === 0) return { friends: [], count: 0 };
|
||||||
|
|
||||||
|
const votes = await prisma.strawPollVote.findMany({
|
||||||
|
where: {
|
||||||
|
pollId,
|
||||||
|
userId: { in: visibleIds },
|
||||||
|
},
|
||||||
|
distinct: ['userId'],
|
||||||
|
include: {
|
||||||
|
user: { select: { id: true, name: true, email: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
friends: votes
|
||||||
|
.filter((v) => v.user)
|
||||||
|
.map((v) => ({
|
||||||
|
id: v.user!.id,
|
||||||
|
name: v.user!.name,
|
||||||
|
email: v.user!.email,
|
||||||
|
})),
|
||||||
|
count: votes.filter((v) => v.user).length,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
/** Helper: get user IDs that have showInFriendActivity disabled */
|
/** Helper: get user IDs that have showInFriendActivity disabled */
|
||||||
async getHiddenActivityUserIds(userIds: string[]): Promise<Set<string>> {
|
async getHiddenActivityUserIds(userIds: string[]): Promise<Set<string>> {
|
||||||
const hidden = await prisma.privacySettings.findMany({
|
const hidden = await prisma.privacySettings.findMany({
|
||||||
|
|||||||
@ -25,6 +25,10 @@ const TYPE_TO_PREF: Record<string, string> = {
|
|||||||
shift_cancelled: 'enableSystemUpdates',
|
shift_cancelled: 'enableSystemUpdates',
|
||||||
canvass_session_summary: 'enableSystemUpdates',
|
canvass_session_summary: 'enableSystemUpdates',
|
||||||
reengagement: 'enableSystemUpdates',
|
reengagement: 'enableSystemUpdates',
|
||||||
|
// Straw poll notification types
|
||||||
|
poll_closed: 'enableSystemUpdates',
|
||||||
|
poll_results_available: 'enableSystemUpdates',
|
||||||
|
poll_challenge: 'enableFriendRequests',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const notificationService = {
|
export const notificationService = {
|
||||||
|
|||||||
@ -39,6 +39,30 @@ async function requireEventPermission(req: Request, _res: Response, next: NextFu
|
|||||||
return next({ status: 403, message: 'Insufficient permissions' });
|
return next({ status: 403, message: 'Insufficient permissions' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Middleware: for :id routes, verify non-admin users own the event */
|
||||||
|
async function requireEventOwnership(req: Request, res: Response, next: NextFunction) {
|
||||||
|
const eventId = req.params.id as string;
|
||||||
|
if (!eventId) return next();
|
||||||
|
|
||||||
|
const userRoles = req.user!.roles || [req.user!.role];
|
||||||
|
const isAdmin = userRoles.some(r => EVENTS_ROLES.includes(r as UserRole));
|
||||||
|
if (isAdmin) return next();
|
||||||
|
|
||||||
|
const event = await prisma.ticketedEvent.findUnique({
|
||||||
|
where: { id: eventId },
|
||||||
|
select: { createdByUserId: true },
|
||||||
|
});
|
||||||
|
if (!event) {
|
||||||
|
res.status(404).json({ error: { message: 'Event not found', code: 'NOT_FOUND' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.createdByUserId !== req.user!.id) {
|
||||||
|
res.status(403).json({ error: { message: 'Forbidden', code: 'FORBIDDEN' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
// All routes require auth + event permission
|
// All routes require auth + event permission
|
||||||
router.use(authenticate, requireEventPermission);
|
router.use(authenticate, requireEventPermission);
|
||||||
|
|
||||||
@ -73,7 +97,7 @@ router.post('/', validate(createEventSchema), async (req: Request, res: Response
|
|||||||
});
|
});
|
||||||
|
|
||||||
// GET /:id — event detail
|
// GET /:id — event detail
|
||||||
router.get('/:id', async (req: Request, res: Response, next: NextFunction) => {
|
router.get('/:id', requireEventOwnership, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const event = await ticketedEventsService.findById(req.params.id as string);
|
const event = await ticketedEventsService.findById(req.params.id as string);
|
||||||
res.json(event);
|
res.json(event);
|
||||||
@ -144,7 +168,7 @@ router.post('/:id/complete', requireRole(...EVENTS_ROLES), async (req: Request,
|
|||||||
// --- Meeting ---
|
// --- Meeting ---
|
||||||
|
|
||||||
// POST /:id/meeting-token — generate moderator JWT for Jitsi
|
// POST /:id/meeting-token — generate moderator JWT for Jitsi
|
||||||
router.post('/:id/meeting-token', async (req: Request, res: Response, next: NextFunction) => {
|
router.post('/:id/meeting-token', requireEventOwnership, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findUnique({
|
||||||
where: { id: req.user!.id },
|
where: { id: req.user!.id },
|
||||||
@ -159,7 +183,7 @@ router.post('/:id/meeting-token', async (req: Request, res: Response, next: Next
|
|||||||
// --- Tiers ---
|
// --- Tiers ---
|
||||||
|
|
||||||
// POST /:id/tiers
|
// POST /:id/tiers
|
||||||
router.post('/:id/tiers', validate(createTierSchema), async (req: Request, res: Response, next: NextFunction) => {
|
router.post('/:id/tiers', requireEventOwnership, validate(createTierSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const tier = await ticketedEventsService.addTier(req.params.id as string, req.body);
|
const tier = await ticketedEventsService.addTier(req.params.id as string, req.body);
|
||||||
res.status(201).json(tier);
|
res.status(201).json(tier);
|
||||||
@ -167,7 +191,7 @@ router.post('/:id/tiers', validate(createTierSchema), async (req: Request, res:
|
|||||||
});
|
});
|
||||||
|
|
||||||
// PUT /:id/tiers/:tierId
|
// PUT /:id/tiers/:tierId
|
||||||
router.put('/:id/tiers/:tierId', validate(updateTierSchema), async (req: Request, res: Response, next: NextFunction) => {
|
router.put('/:id/tiers/:tierId', requireEventOwnership, validate(updateTierSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const tier = await ticketedEventsService.updateTier(
|
const tier = await ticketedEventsService.updateTier(
|
||||||
req.params.tierId as string,
|
req.params.tierId as string,
|
||||||
@ -179,7 +203,7 @@ router.put('/:id/tiers/:tierId', validate(updateTierSchema), async (req: Request
|
|||||||
});
|
});
|
||||||
|
|
||||||
// DELETE /:id/tiers/:tierId
|
// DELETE /:id/tiers/:tierId
|
||||||
router.delete('/:id/tiers/:tierId', async (req: Request, res: Response, next: NextFunction) => {
|
router.delete('/:id/tiers/:tierId', requireEventOwnership, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
await ticketedEventsService.deleteTier(req.params.tierId as string, req.params.id as string);
|
await ticketedEventsService.deleteTier(req.params.tierId as string, req.params.id as string);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
@ -189,7 +213,7 @@ router.delete('/:id/tiers/:tierId', async (req: Request, res: Response, next: Ne
|
|||||||
// --- Tickets ---
|
// --- Tickets ---
|
||||||
|
|
||||||
// GET /:id/tickets
|
// GET /:id/tickets
|
||||||
router.get('/:id/tickets', async (req: Request, res: Response, next: NextFunction) => {
|
router.get('/:id/tickets', requireEventOwnership, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const page = parseInt(req.query.page as string) || 1;
|
const page = parseInt(req.query.page as string) || 1;
|
||||||
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
|
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
|
||||||
@ -201,7 +225,7 @@ router.get('/:id/tickets', async (req: Request, res: Response, next: NextFunctio
|
|||||||
});
|
});
|
||||||
|
|
||||||
// GET /:id/checkins
|
// GET /:id/checkins
|
||||||
router.get('/:id/checkins', async (req: Request, res: Response, next: NextFunction) => {
|
router.get('/:id/checkins', requireEventOwnership, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const page = parseInt(req.query.page as string) || 1;
|
const page = parseInt(req.query.page as string) || 1;
|
||||||
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
|
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
|
||||||
@ -211,7 +235,7 @@ router.get('/:id/checkins', async (req: Request, res: Response, next: NextFuncti
|
|||||||
});
|
});
|
||||||
|
|
||||||
// GET /:id/stats
|
// GET /:id/stats
|
||||||
router.get('/:id/stats', async (req: Request, res: Response, next: NextFunction) => {
|
router.get('/:id/stats', requireEventOwnership, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const stats = await ticketedEventsService.getEventStats(req.params.id as string);
|
const stats = await ticketedEventsService.getEventStats(req.params.id as string);
|
||||||
res.json(stats);
|
res.json(stats);
|
||||||
@ -219,7 +243,7 @@ router.get('/:id/stats', async (req: Request, res: Response, next: NextFunction)
|
|||||||
});
|
});
|
||||||
|
|
||||||
// POST /:id/resend-ticket/:ticketId
|
// POST /:id/resend-ticket/:ticketId
|
||||||
router.post('/:id/resend-ticket/:ticketId', async (req: Request, res: Response, next: NextFunction) => {
|
router.post('/:id/resend-ticket/:ticketId', requireEventOwnership, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const ticket = await prisma.ticket.findUnique({
|
const ticket = await prisma.ticket.findUnique({
|
||||||
where: { id: req.params.ticketId as string },
|
where: { id: req.params.ticketId as string },
|
||||||
@ -273,7 +297,7 @@ router.post('/:id/resend-ticket/:ticketId', async (req: Request, res: Response,
|
|||||||
});
|
});
|
||||||
|
|
||||||
// POST /:id/tickets/:ticketId/cancel
|
// POST /:id/tickets/:ticketId/cancel
|
||||||
router.post('/:id/tickets/:ticketId/cancel', async (req: Request, res: Response, next: NextFunction) => {
|
router.post('/:id/tickets/:ticketId/cancel', requireEventOwnership, async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
await ticketsService.cancelTicket(req.params.ticketId as string);
|
await ticketsService.cancelTicket(req.params.ticketId as string);
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
|
|||||||
@ -9,6 +9,8 @@ import { getStripe } from '../../services/stripe.client';
|
|||||||
import { prisma } from '../../config/database';
|
import { prisma } from '../../config/database';
|
||||||
import { env } from '../../config/env';
|
import { env } from '../../config/env';
|
||||||
import { AppError } from '../../middleware/error-handler';
|
import { AppError } from '../../middleware/error-handler';
|
||||||
|
import { paymentCheckoutRateLimit } from '../../middleware/rate-limit';
|
||||||
|
import { requirePaymentsEnabled } from '../payments/payment-settings.service';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@ -101,7 +103,7 @@ router.get('/:slug/availability', async (req: Request, res: Response, next: Next
|
|||||||
});
|
});
|
||||||
|
|
||||||
// POST /:slug/checkout — create Stripe checkout for paid ticket
|
// POST /:slug/checkout — create Stripe checkout for paid ticket
|
||||||
router.post('/:slug/checkout', optionalAuth, validate(checkoutSchema), async (req: Request, res: Response, next: NextFunction) => {
|
router.post('/:slug/checkout', requirePaymentsEnabled, paymentCheckoutRateLimit, optionalAuth, validate(checkoutSchema), async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
try {
|
||||||
const slug = req.params.slug as string;
|
const slug = req.params.slug as string;
|
||||||
const { tierId, quantity, buyerEmail, buyerName } = req.body;
|
const { tierId, quantity, buyerEmail, buyerName } = req.body;
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { prisma } from '../../config/database';
|
import { prisma } from '../../config/database';
|
||||||
import { TicketedEventStatus, TicketedEventVisibility, EventFormat, Prisma } from '@prisma/client';
|
import { TicketedEventStatus, TicketedEventVisibility, EventFormat, Prisma } from '@prisma/client';
|
||||||
import { logger } from '../../utils/logger';
|
|
||||||
import { AppError } from '../../middleware/error-handler';
|
import { AppError } from '../../middleware/error-handler';
|
||||||
import { unifiedCalendarService } from '../events/unified-calendar.service';
|
import { unifiedCalendarService } from '../events/unified-calendar.service';
|
||||||
import { siteSettingsService } from '../settings/settings.service';
|
import { siteSettingsService } from '../settings/settings.service';
|
||||||
@ -9,6 +8,7 @@ import { generateSlug as generateMeetingSlug } from '../../utils/slug';
|
|||||||
import { env } from '../../config/env';
|
import { env } from '../../config/env';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { EVENTS_ROLES } from '../../utils/roles';
|
import { EVENTS_ROLES } from '../../utils/roles';
|
||||||
|
import { eventBus } from '../../services/event-bus.service';
|
||||||
|
|
||||||
function generateSlug(title: string): string {
|
function generateSlug(title: string): string {
|
||||||
return title
|
return title
|
||||||
@ -384,10 +384,19 @@ export const ticketedEventsService = {
|
|||||||
include: { ticketTiers: true },
|
include: { ticketTiers: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Gancio sync + calendar cache bust (fire-and-forget)
|
// Calendar cache bust (fire-and-forget)
|
||||||
this.syncToGancio(updated).catch(() => {});
|
|
||||||
unifiedCalendarService.bustCache().catch(() => {});
|
unifiedCalendarService.bustCache().catch(() => {});
|
||||||
|
|
||||||
|
eventBus.publish('ticketed-event.published', {
|
||||||
|
eventId: updated.id,
|
||||||
|
title: updated.title,
|
||||||
|
date: updated.date.toISOString().split('T')[0],
|
||||||
|
startTime: updated.startTime,
|
||||||
|
endTime: updated.endTime,
|
||||||
|
location: updated.venueAddress || updated.venueName || undefined,
|
||||||
|
gancioEventId: updated.gancioEventId ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -404,8 +413,18 @@ export const ticketedEventsService = {
|
|||||||
include: { ticketTiers: true },
|
include: { ticketTiers: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
this.syncToGancio(updated).catch(() => {});
|
|
||||||
unifiedCalendarService.bustCache().catch(() => {});
|
unifiedCalendarService.bustCache().catch(() => {});
|
||||||
|
|
||||||
|
eventBus.publish('ticketed-event.published', {
|
||||||
|
eventId: updated.id,
|
||||||
|
title: updated.title,
|
||||||
|
date: updated.date.toISOString().split('T')[0],
|
||||||
|
startTime: updated.startTime,
|
||||||
|
endTime: updated.endTime,
|
||||||
|
location: updated.venueAddress || updated.venueName || undefined,
|
||||||
|
gancioEventId: updated.gancioEventId ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -445,12 +464,14 @@ export const ticketedEventsService = {
|
|||||||
data: { status: 'CANCELLED' },
|
data: { status: 'CANCELLED' },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete from Gancio if synced + bust calendar cache
|
// Calendar cache bust (fire-and-forget)
|
||||||
if (event.gancioEventId) {
|
|
||||||
this.deleteFromGancio(event.gancioEventId).catch(() => {});
|
|
||||||
}
|
|
||||||
unifiedCalendarService.bustCache().catch(() => {});
|
unifiedCalendarService.bustCache().catch(() => {});
|
||||||
|
|
||||||
|
eventBus.publish('ticketed-event.cancelled', {
|
||||||
|
eventId: updated.id,
|
||||||
|
title: event.title,
|
||||||
|
});
|
||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -485,7 +506,10 @@ export const ticketedEventsService = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.gancioEventId) {
|
if (event.gancioEventId) {
|
||||||
this.deleteFromGancio(event.gancioEventId).catch(() => {});
|
eventBus.publish('ticketed-event.cancelled', {
|
||||||
|
eventId: event.id,
|
||||||
|
title: event.title,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.ticketedEvent.delete({ where: { id } });
|
await prisma.ticketedEvent.delete({ where: { id } });
|
||||||
@ -767,78 +791,4 @@ export const ticketedEventsService = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
// --- Gancio Sync ---
|
|
||||||
|
|
||||||
async syncToGancio(event: {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
description?: string | null;
|
|
||||||
venueAddress?: string | null;
|
|
||||||
venueName?: string | null;
|
|
||||||
eventFormat?: EventFormat;
|
|
||||||
date: Date;
|
|
||||||
startTime: string;
|
|
||||||
endTime: string;
|
|
||||||
gancioEventId?: number | null;
|
|
||||||
}) {
|
|
||||||
try {
|
|
||||||
const { gancioClient } = await import('../../services/gancio.client');
|
|
||||||
if (!gancioClient.enabled) return;
|
|
||||||
|
|
||||||
// Determine location based on event format
|
|
||||||
let location: string | null;
|
|
||||||
const format = event.eventFormat || 'IN_PERSON';
|
|
||||||
if (format === 'ONLINE') {
|
|
||||||
location = 'Online Event';
|
|
||||||
} else if (format === 'HYBRID') {
|
|
||||||
const venue = event.venueAddress || event.venueName || '';
|
|
||||||
location = venue ? `${venue} (also streaming online)` : 'Online + In-Person';
|
|
||||||
} else {
|
|
||||||
location = event.venueAddress || event.venueName || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tags = ['ticketed', 'community'];
|
|
||||||
if (format === 'ONLINE') tags.push('online');
|
|
||||||
if (format === 'HYBRID') tags.push('hybrid');
|
|
||||||
|
|
||||||
if (event.gancioEventId) {
|
|
||||||
await gancioClient.updateEvent(event.gancioEventId, {
|
|
||||||
title: event.title,
|
|
||||||
description: event.description,
|
|
||||||
location,
|
|
||||||
date: event.date,
|
|
||||||
startTime: event.startTime,
|
|
||||||
endTime: event.endTime,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const gancioId = await gancioClient.createEvent({
|
|
||||||
title: event.title,
|
|
||||||
description: event.description,
|
|
||||||
location,
|
|
||||||
date: event.date,
|
|
||||||
startTime: event.startTime,
|
|
||||||
endTime: event.endTime,
|
|
||||||
tags,
|
|
||||||
});
|
|
||||||
if (gancioId) {
|
|
||||||
await prisma.ticketedEvent.update({
|
|
||||||
where: { id: event.id },
|
|
||||||
data: { gancioEventId: gancioId },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn('Gancio sync failed for ticketed event:', err instanceof Error ? err.message : err);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async deleteFromGancio(gancioEventId: number) {
|
|
||||||
try {
|
|
||||||
const { gancioClient } = await import('../../services/gancio.client');
|
|
||||||
if (!gancioClient.enabled) return;
|
|
||||||
await gancioClient.deleteEvent(gancioEventId);
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn(`Gancio delete failed for event ${gancioEventId}:`, err instanceof Error ? err.message : err);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { requireRole } from '../../middleware/rbac.middleware';
|
|||||||
import { hasAnyRole, ADMIN_ROLES, getUserRoles } from '../../utils/roles';
|
import { hasAnyRole, ADMIN_ROLES, getUserRoles } from '../../utils/roles';
|
||||||
import { prisma } from '../../config/database';
|
import { prisma } from '../../config/database';
|
||||||
import { emailService } from '../../services/email.service';
|
import { emailService } from '../../services/email.service';
|
||||||
|
import { eventBus } from '../../services/event-bus.service';
|
||||||
import { env } from '../../config/env';
|
import { env } from '../../config/env';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
import { userProvisioningService } from '../../services/user-provisioning/provisioning.service';
|
import { userProvisioningService } from '../../services/user-provisioning/provisioning.service';
|
||||||
@ -115,7 +116,7 @@ router.put(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Self-service password change requires current password verification
|
// Self-service password change requires current password verification
|
||||||
if (isSelf && !isAdminUser && req.body.password) {
|
if (isSelf && req.body.password) {
|
||||||
if (!req.body.currentPassword) {
|
if (!req.body.currentPassword) {
|
||||||
res.status(400).json({ error: { message: 'Current password is required to change your password', code: 'CURRENT_PASSWORD_REQUIRED' } });
|
res.status(400).json({ error: { message: 'Current password is required to change your password', code: 'CURRENT_PASSWORD_REQUIRED' } });
|
||||||
return;
|
return;
|
||||||
@ -183,6 +184,14 @@ router.post(
|
|||||||
roles: user.roles, status: 'ACTIVE', permissions: user.permissions as Record<string, unknown> | null,
|
roles: user.roles, status: 'ACTIVE', permissions: user.permissions as Record<string, unknown> | null,
|
||||||
}).catch(err => logger.warn('User provisioning hook (approve) failed:', err));
|
}).catch(err => logger.warn('User provisioning hook (approve) failed:', err));
|
||||||
|
|
||||||
|
eventBus.publish('user.approved', {
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name || '',
|
||||||
|
role: user.role,
|
||||||
|
approvedByUserId: req.user!.id,
|
||||||
|
});
|
||||||
|
|
||||||
res.json({ message: 'User approved', userId: id });
|
res.json({ message: 'User approved', userId: id });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { prisma } from '../../config/database';
|
|||||||
import { AppError } from '../../middleware/error-handler';
|
import { AppError } from '../../middleware/error-handler';
|
||||||
import { getPrimaryRole } from '../../utils/roles';
|
import { getPrimaryRole } from '../../utils/roles';
|
||||||
import { userProvisioningService } from '../../services/user-provisioning/provisioning.service';
|
import { userProvisioningService } from '../../services/user-provisioning/provisioning.service';
|
||||||
|
import { eventBus } from '../../services/event-bus.service';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
import type { CMUser } from '../../services/user-provisioning/provisioner.interface';
|
import type { CMUser } from '../../services/user-provisioning/provisioner.interface';
|
||||||
import type { CreateUserInput, UpdateUserInput, ListUsersInput } from './users.schemas';
|
import type { CreateUserInput, UpdateUserInput, ListUsersInput } from './users.schemas';
|
||||||
@ -122,6 +123,13 @@ export const usersService = {
|
|||||||
logger.warn('User provisioning hook (create) failed:', err);
|
logger.warn('User provisioning hook (create) failed:', err);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
eventBus.publish('user.created', {
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name || '',
|
||||||
|
role: user.role,
|
||||||
|
});
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -182,6 +190,16 @@ export const usersService = {
|
|||||||
logger.warn('User provisioning hook (update) failed:', err);
|
logger.warn('User provisioning hook (update) failed:', err);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Compute list of changed fields for the event payload
|
||||||
|
const changes = Object.keys(data).filter(k => k !== 'currentPassword');
|
||||||
|
eventBus.publish('user.updated', {
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
name: user.name || '',
|
||||||
|
role: user.role,
|
||||||
|
changes,
|
||||||
|
});
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -198,6 +216,12 @@ export const usersService = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await prisma.user.delete({ where: { id } });
|
await prisma.user.delete({ where: { id } });
|
||||||
|
|
||||||
|
eventBus.publish('user.deleted', {
|
||||||
|
userId: existing.id,
|
||||||
|
email: existing.email,
|
||||||
|
name: existing.name || '',
|
||||||
|
});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import { authenticate } from './middleware/auth.middleware';
|
|||||||
import { requireRole } from './middleware/rbac.middleware';
|
import { requireRole } from './middleware/rbac.middleware';
|
||||||
import { globalRateLimit, healthMetricsRateLimit } from './middleware/rate-limit';
|
import { globalRateLimit, healthMetricsRateLimit } from './middleware/rate-limit';
|
||||||
import { authRouter } from './modules/auth/auth.routes';
|
import { authRouter } from './modules/auth/auth.routes';
|
||||||
|
import { giteaSsoRouter } from './modules/auth/gitea-sso.routes';
|
||||||
import { usersRouter } from './modules/users/users.routes';
|
import { usersRouter } from './modules/users/users.routes';
|
||||||
import { provisioningRouter } from './modules/users/provisioning.routes';
|
import { provisioningRouter } from './modules/users/provisioning.routes';
|
||||||
import { campaignsRouter } from './modules/influence/campaigns/campaigns.routes';
|
import { campaignsRouter } from './modules/influence/campaigns/campaigns.routes';
|
||||||
@ -34,6 +35,9 @@ import { qrRouter } from './modules/qr/qr.routes';
|
|||||||
import { listmonkRouter } from './modules/listmonk/listmonk.routes';
|
import { listmonkRouter } from './modules/listmonk/listmonk.routes';
|
||||||
import { listmonkWebhookRouter } from './modules/listmonk/listmonk-webhook.routes';
|
import { listmonkWebhookRouter } from './modules/listmonk/listmonk-webhook.routes';
|
||||||
import { meetingPlannerAdminRouter, meetingPlannerPublicRouter } from './modules/meeting-planner/meeting-planner.routes';
|
import { meetingPlannerAdminRouter, meetingPlannerPublicRouter } from './modules/meeting-planner/meeting-planner.routes';
|
||||||
|
import { strawPollAdminRouter } from './modules/polls/polls.routes';
|
||||||
|
import { strawPollPublicRouter } from './modules/polls/polls-public.routes';
|
||||||
|
import { strawPollWidgetRouter } from './modules/polls/polls-widget.routes';
|
||||||
import { pagesPublicRouter } from './modules/pages/pages-public.routes';
|
import { pagesPublicRouter } from './modules/pages/pages-public.routes';
|
||||||
import { pagesAdminRouter } from './modules/pages/pages-admin.routes';
|
import { pagesAdminRouter } from './modules/pages/pages-admin.routes';
|
||||||
import { blocksRouter } from './modules/pages/blocks.routes';
|
import { blocksRouter } from './modules/pages/blocks.routes';
|
||||||
@ -123,12 +127,16 @@ import { autoUpgradeService } from './services/auto-upgrade.service';
|
|||||||
import { calendarFeedQueueService } from './services/calendar-feed-queue.service';
|
import { calendarFeedQueueService } from './services/calendar-feed-queue.service';
|
||||||
import { scheduledJobsQueueService } from './services/scheduled-jobs-queue.service';
|
import { scheduledJobsQueueService } from './services/scheduled-jobs-queue.service';
|
||||||
import { pollAutoFinalizeQueueService } from './services/poll-auto-finalize-queue.service';
|
import { pollAutoFinalizeQueueService } from './services/poll-auto-finalize-queue.service';
|
||||||
|
import { pollAutoCloseQueueService } from './services/poll-auto-close-queue.service';
|
||||||
|
import { pollSseService } from './modules/polls/polls-sse.service';
|
||||||
import { agendaRouter } from './modules/meetings/agenda.routes';
|
import { agendaRouter } from './modules/meetings/agenda.routes';
|
||||||
import { actionItemsRouter } from './modules/meetings/action-items.routes';
|
import { actionItemsRouter } from './modules/meetings/action-items.routes';
|
||||||
import { WebSocketServer } from 'ws';
|
import { WebSocketServer } from 'ws';
|
||||||
import { docsCollabService } from './modules/docs/docs-collab.service';
|
import { docsCollabService } from './modules/docs/docs-collab.service';
|
||||||
import { correlationId } from './middleware/correlation-id';
|
import { correlationId } from './middleware/correlation-id';
|
||||||
import cookieParser from 'cookie-parser';
|
import cookieParser from 'cookie-parser';
|
||||||
|
import { registerAllEventListeners } from './services/event-listeners';
|
||||||
|
import { eventBus } from './services/event-bus.service';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
@ -272,6 +280,7 @@ app.get('/api/metrics/internal', async (req, res) => {
|
|||||||
|
|
||||||
// --- API Routes ---
|
// --- API Routes ---
|
||||||
app.use('/api/auth', authRouter);
|
app.use('/api/auth', authRouter);
|
||||||
|
app.use('/api/auth', giteaSsoRouter); // Gitea SSO validation (nginx auth_request)
|
||||||
app.use('/api/users', usersRouter);
|
app.use('/api/users', usersRouter);
|
||||||
app.use('/api/users', provisioningRouter); // User provisioning management (ADMIN roles)
|
app.use('/api/users', provisioningRouter); // User provisioning management (ADMIN roles)
|
||||||
app.use('/api/campaigns', campaignPublicRouter); // Public campaign details (no auth)
|
app.use('/api/campaigns', campaignPublicRouter); // Public campaign details (no auth)
|
||||||
@ -301,6 +310,9 @@ app.use('/api/map/settings', mapSettingsRouter); // Map settings (public
|
|||||||
app.use('/api/map/events', eventsPublicRouter); // Public map events from Gancio (no auth)
|
app.use('/api/map/events', eventsPublicRouter); // Public map events from Gancio (no auth)
|
||||||
app.use('/api/meeting-planner', meetingPlannerPublicRouter); // Public poll viewing + voting (no auth)
|
app.use('/api/meeting-planner', meetingPlannerPublicRouter); // Public poll viewing + voting (no auth)
|
||||||
app.use('/api/meeting-planner', meetingPlannerAdminRouter); // Admin poll CRUD (auth required)
|
app.use('/api/meeting-planner', meetingPlannerAdminRouter); // Admin poll CRUD (auth required)
|
||||||
|
app.use('/api/straw-polls', strawPollPublicRouter); // Public straw poll voting + viewing (no auth)
|
||||||
|
app.use('/api/straw-polls', strawPollWidgetRouter); // Straw poll widget endpoint (no auth, cached)
|
||||||
|
app.use('/api/straw-polls', strawPollAdminRouter); // Admin straw poll CRUD (auth required)
|
||||||
app.use('/api/meetings/agendas', agendaRouter); // Meeting agendas + minutes (EVENTS roles)
|
app.use('/api/meetings/agendas', agendaRouter); // Meeting agendas + minutes (EVENTS roles)
|
||||||
app.use('/api/meetings/action-items', actionItemsRouter); // Action items CRUD (EVENTS roles / auth)
|
app.use('/api/meetings/action-items', actionItemsRouter); // Action items CRUD (EVENTS roles / auth)
|
||||||
app.use('/api/qr', qrRouter); // QR code generation (public)
|
app.use('/api/qr', qrRouter); // QR code generation (public)
|
||||||
@ -390,6 +402,9 @@ async function start() {
|
|||||||
// Register user provisioning framework
|
// Register user provisioning framework
|
||||||
registerProvisioners();
|
registerProvisioners();
|
||||||
|
|
||||||
|
// Register EventBus listeners (Listmonk, RC, CRM, Calendar, n8n, Gancio)
|
||||||
|
registerAllEventListeners();
|
||||||
|
|
||||||
// Rebuild SMTP transporter from DB settings (env fallback for empty fields)
|
// Rebuild SMTP transporter from DB settings (env fallback for empty fields)
|
||||||
await emailService.rebuildTransporter();
|
await emailService.rebuildTransporter();
|
||||||
|
|
||||||
@ -399,6 +414,7 @@ async function start() {
|
|||||||
calendarFeedQueueService.startWorker();
|
calendarFeedQueueService.startWorker();
|
||||||
scheduledJobsQueueService.startWorker();
|
scheduledJobsQueueService.startWorker();
|
||||||
pollAutoFinalizeQueueService.startWorker();
|
pollAutoFinalizeQueueService.startWorker();
|
||||||
|
pollAutoCloseQueueService.startWorker();
|
||||||
startProxy();
|
startProxy();
|
||||||
|
|
||||||
// Load SMS config from DB (env fallback for empty fields)
|
// Load SMS config from DB (env fallback for empty fields)
|
||||||
@ -432,6 +448,7 @@ async function start() {
|
|||||||
// SSE + Presence: mark all users offline on startup, start heartbeat + stale cleanup
|
// SSE + Presence: mark all users offline on startup, start heartbeat + stale cleanup
|
||||||
presenceService.markAllOffline().catch(() => {});
|
presenceService.markAllOffline().catch(() => {});
|
||||||
sseService.startHeartbeat();
|
sseService.startHeartbeat();
|
||||||
|
pollSseService.startHeartbeat();
|
||||||
setInterval(() => presenceService.cleanupStale().catch(() => {}), 60 * 1000); // every 1 min
|
setInterval(() => presenceService.cleanupStale().catch(() => {}), 60 * 1000); // every 1 min
|
||||||
|
|
||||||
// Challenge lifecycle: activate/complete/score every 5 minutes
|
// Challenge lifecycle: activate/complete/score every 5 minutes
|
||||||
@ -543,6 +560,7 @@ for (const signal of ['SIGTERM', 'SIGINT']) {
|
|||||||
process.on(signal, async () => {
|
process.on(signal, async () => {
|
||||||
logger.info(`${signal} received, shutting down...`);
|
logger.info(`${signal} received, shutting down...`);
|
||||||
sseService.closeAll();
|
sseService.closeAll();
|
||||||
|
pollSseService.closeAll();
|
||||||
await docsCollabService.shutdown();
|
await docsCollabService.shutdown();
|
||||||
await stopProxy();
|
await stopProxy();
|
||||||
await emailQueueService.close();
|
await emailQueueService.close();
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { prisma } from '../config/database';
|
|||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
import { emailService } from './email.service';
|
import { emailService } from './email.service';
|
||||||
import { recordEmailSent, recordEmailFailed, setEmailQueueSize, emailSendDuration } from '../utils/metrics';
|
import { recordEmailSent, recordEmailFailed, setEmailQueueSize, emailSendDuration } from '../utils/metrics';
|
||||||
import { listmonkEventSyncService } from './listmonk-event-sync.service';
|
import { eventBus } from './event-bus.service';
|
||||||
|
|
||||||
interface CampaignEmailJobData {
|
interface CampaignEmailJobData {
|
||||||
campaignEmailId: string;
|
campaignEmailId: string;
|
||||||
@ -66,13 +66,13 @@ class EmailQueueService {
|
|||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
recordEmailSent(campaignId);
|
recordEmailSent(campaignId);
|
||||||
// Listmonk event sync
|
// Publish campaign email sent event
|
||||||
listmonkEventSyncService.onCampaignEmailSent({
|
eventBus.publish('campaign.email.sent', {
|
||||||
email: emailData.userEmail,
|
email: emailData.userEmail,
|
||||||
name: emailData.userName,
|
name: emailData.userName,
|
||||||
campaignSlug: emailData.campaignTitle,
|
campaignSlug: emailData.campaignTitle,
|
||||||
postalCode: emailData.postalCode,
|
postalCode: emailData.postalCode,
|
||||||
}).catch(() => {});
|
});
|
||||||
} else {
|
} else {
|
||||||
recordEmailFailed(campaignId, 'send_failure');
|
recordEmailFailed(campaignId, 'send_failure');
|
||||||
throw new Error(`Failed to send email to ${emailData.recipientEmail}`);
|
throw new Error(`Failed to send email to ${emailData.recipientEmail}`);
|
||||||
|
|||||||
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>,
|
{ name: tokenName, scopes: ['read', 'write'] as unknown as Record<string, unknown> } as unknown as Record<string, unknown>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Repository Collaborator Management ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a collaborator to a repository with the specified permission level.
|
||||||
|
* @param permission - "read", "write", or "admin"
|
||||||
|
*/
|
||||||
|
async addCollaborator(
|
||||||
|
owner: string,
|
||||||
|
repo: string,
|
||||||
|
username: string,
|
||||||
|
permission: 'read' | 'write' | 'admin' = 'write',
|
||||||
|
): Promise<void> {
|
||||||
|
await this.request(
|
||||||
|
'PUT',
|
||||||
|
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/collaborators/${encodeURIComponent(username)}`,
|
||||||
|
{ permission },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a collaborator from a repository.
|
||||||
|
*/
|
||||||
|
async removeCollaborator(owner: string, repo: string, username: string): Promise<void> {
|
||||||
|
await this.request(
|
||||||
|
'DELETE',
|
||||||
|
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/collaborators/${encodeURIComponent(username)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a user is a collaborator on a repository.
|
||||||
|
* Returns true if they are, false otherwise.
|
||||||
|
*/
|
||||||
|
async isCollaborator(owner: string, repo: string, username: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await this.request(
|
||||||
|
'GET',
|
||||||
|
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/collaborators/${encodeURIComponent(username)}`,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const giteaClient = new GiteaClient();
|
export const giteaClient = new GiteaClient();
|
||||||
|
|||||||
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 { logger } from '../utils/logger';
|
||||||
import { siteSettingsService } from '../modules/settings/settings.service';
|
import { siteSettingsService } from '../modules/settings/settings.service';
|
||||||
import { notificationQueueService } from './notification-queue.service';
|
import { notificationQueueService } from './notification-queue.service';
|
||||||
import { listmonkEventSyncService } from './listmonk-event-sync.service';
|
import { eventBus } from './event-bus.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Volunteer Re-Engagement Scanner
|
* Volunteer Re-Engagement Scanner
|
||||||
@ -76,11 +76,11 @@ class ReengagementService {
|
|||||||
const cooldownSeconds = cooldownDays * 24 * 60 * 60;
|
const cooldownSeconds = cooldownDays * 24 * 60 * 60;
|
||||||
await redis.set(cooldownKey, '', 'EX', cooldownSeconds);
|
await redis.set(cooldownKey, '', 'EX', cooldownSeconds);
|
||||||
|
|
||||||
// Listmonk event sync: tag as re-engaged
|
// Publish re-engagement event
|
||||||
listmonkEventSyncService.onReengagementSent({
|
eventBus.publish('reengagement.sent', {
|
||||||
email: volunteer.email,
|
email: volunteer.email,
|
||||||
name: volunteer.name || volunteer.email,
|
name: volunteer.name || volunteer.email,
|
||||||
}).catch(() => {});
|
});
|
||||||
|
|
||||||
sent++;
|
sent++;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@ -58,6 +58,63 @@ class RocketChatWebhookService {
|
|||||||
await this.notify('#campaigns', text, '#9b59b6');
|
await this.notify('#campaigns', text, '#9b59b6');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async onCampaignPublished(data: {
|
||||||
|
campaignTitle: string;
|
||||||
|
campaignSlug: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const text = `:newspaper: Campaign published: *${data.campaignTitle}* → /campaigns/${data.campaignSlug}`;
|
||||||
|
await this.notify('#campaigns', text, '#27ae60');
|
||||||
|
}
|
||||||
|
|
||||||
|
async onDonationReceived(data: {
|
||||||
|
donorName: string;
|
||||||
|
amount: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const text = `:money_with_wings: **${data.donorName}** donated **$${data.amount}**`;
|
||||||
|
await this.notify('#campaigns', text, '#f1c40f');
|
||||||
|
}
|
||||||
|
|
||||||
|
async onSubscriptionActivated(data: {
|
||||||
|
userName: string;
|
||||||
|
planName: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const text = `:star: **${data.userName}** subscribed to *${data.planName}*`;
|
||||||
|
await this.notify('#campaigns', text, '#9b59b6');
|
||||||
|
}
|
||||||
|
|
||||||
|
async onSmsEscalation(data: {
|
||||||
|
phone: string;
|
||||||
|
responseType: string;
|
||||||
|
body: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const preview = data.body.length > 100 ? data.body.slice(0, 100) + '...' : data.body;
|
||||||
|
const text = `:warning: SMS ${data.responseType} from ${data.phone}: "${preview}"`;
|
||||||
|
await this.notify('#campaigns', text, '#e74c3c');
|
||||||
|
}
|
||||||
|
|
||||||
|
async onUserApproved(data: {
|
||||||
|
userName: string;
|
||||||
|
role: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const text = `:white_check_mark: User approved: **${data.userName}** (${data.role})`;
|
||||||
|
await this.notify('#campaigns', text, '#2ecc71');
|
||||||
|
}
|
||||||
|
|
||||||
|
async onVideoPublished(data: {
|
||||||
|
videoTitle: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const text = `:film_projector: New video published: *${data.videoTitle}*`;
|
||||||
|
await this.notify('#campaigns', text, '#3498db');
|
||||||
|
}
|
||||||
|
|
||||||
|
async onTicketedEventPublished(data: {
|
||||||
|
eventTitle: string;
|
||||||
|
eventDate: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
const text = `:ticket: Event published: *${data.eventTitle}* (${data.eventDate})`;
|
||||||
|
await this.notify('#campaigns', text, '#e67e22');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure default notification channels exist in Rocket.Chat.
|
* Ensure default notification channels exist in Rocket.Chat.
|
||||||
* Called during service startup.
|
* Called during service startup.
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { env } from '../config/env';
|
|||||||
import { prisma } from '../config/database';
|
import { prisma } from '../config/database';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
import { termuxClient } from './termux.client';
|
import { termuxClient } from './termux.client';
|
||||||
|
import { eventBus } from './event-bus.service';
|
||||||
|
|
||||||
export interface SmsJobData {
|
export interface SmsJobData {
|
||||||
recipientId: string; // empty string for notification jobs
|
recipientId: string; // empty string for notification jobs
|
||||||
@ -129,6 +130,13 @@ class SmsQueueService {
|
|||||||
where: { id: campaignId },
|
where: { id: campaignId },
|
||||||
data: { totalSent: { increment: 1 } },
|
data: { totalSent: { increment: 1 } },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
eventBus.publish('sms.message.sent', {
|
||||||
|
messageId: smsMessage.id,
|
||||||
|
campaignId,
|
||||||
|
phone,
|
||||||
|
body: message,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
await prisma.smsCampaign.update({
|
await prisma.smsCampaign.update({
|
||||||
where: { id: campaignId },
|
where: { id: campaignId },
|
||||||
@ -136,6 +144,23 @@ class SmsQueueService {
|
|||||||
});
|
});
|
||||||
throw new Error(`Failed to send SMS to ${phone}: ${result.error}`);
|
throw new Error(`Failed to send SMS to ${phone}: ${result.error}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if campaign is complete (no more PENDING recipients)
|
||||||
|
const pendingCount = await prisma.smsCampaignRecipient.count({
|
||||||
|
where: { campaignId, status: 'PENDING' },
|
||||||
|
});
|
||||||
|
if (pendingCount === 0) {
|
||||||
|
const updatedCampaign = await prisma.smsCampaign.update({
|
||||||
|
where: { id: campaignId },
|
||||||
|
data: { status: 'COMPLETED', completedAt: new Date() },
|
||||||
|
});
|
||||||
|
eventBus.publish('sms.campaign.completed', {
|
||||||
|
campaignId,
|
||||||
|
title: updatedCampaign.name,
|
||||||
|
sentCount: updatedCampaign.totalSent,
|
||||||
|
failedCount: updatedCampaign.totalFailed,
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Notification job: just throw on failure for BullMQ retry
|
// Notification job: just throw on failure for BullMQ retry
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { env } from '../config/env';
|
|||||||
import { prisma } from '../config/database';
|
import { prisma } from '../config/database';
|
||||||
import { logger } from '../utils/logger';
|
import { logger } from '../utils/logger';
|
||||||
import { termuxClient } from './termux.client';
|
import { termuxClient } from './termux.client';
|
||||||
|
import { eventBus } from './event-bus.service';
|
||||||
import type { SmsResponseType } from '@prisma/client';
|
import type { SmsResponseType } from '@prisma/client';
|
||||||
|
|
||||||
// Opt-out keywords (case-insensitive)
|
// Opt-out keywords (case-insensitive)
|
||||||
@ -116,6 +117,14 @@ class SmsResponseSyncService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
eventBus.publish('sms.message.received', {
|
||||||
|
messageId: smsMessage.id,
|
||||||
|
conversationId: conversation?.id || '',
|
||||||
|
phone: msg.number,
|
||||||
|
body: msg.body,
|
||||||
|
responseType,
|
||||||
|
});
|
||||||
|
|
||||||
// Update conversation stats if we have one
|
// Update conversation stats if we have one
|
||||||
if (conversation) {
|
if (conversation) {
|
||||||
const updates: Record<string, unknown> = {
|
const updates: Record<string, unknown> = {
|
||||||
|
|||||||
@ -19,7 +19,7 @@ export async function getStripe(): Promise<Stripe> {
|
|||||||
throw new Error('Stripe secret key not configured — set it in admin payment settings');
|
throw new Error('Stripe secret key not configured — set it in admin payment settings');
|
||||||
}
|
}
|
||||||
|
|
||||||
_stripe = new Stripe(secretKey);
|
_stripe = new Stripe(secretKey, { apiVersion: '2026-01-28.clover' });
|
||||||
|
|
||||||
logger.info('Stripe client initialized');
|
logger.info('Stripe client initialized');
|
||||||
return _stripe;
|
return _stripe;
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import { createHmac } from 'crypto';
|
import { createHmac } from 'crypto';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma, UserRole } from '@prisma/client';
|
||||||
import { prisma } from '../../config/database';
|
import { prisma } from '../../config/database';
|
||||||
import { env } from '../../config/env';
|
import { env } from '../../config/env';
|
||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
import { giteaClient } from '../gitea.client';
|
import { giteaClient } from '../gitea.client';
|
||||||
|
import { CONTENT_ROLES } from '../../utils/roles';
|
||||||
import type { ServiceProvisioner, ProvisionerConfig, ProvisionResult, CMUser } from './provisioner.interface';
|
import type { ServiceProvisioner, ProvisionerConfig, ProvisionResult, CMUser } from './provisioner.interface';
|
||||||
|
|
||||||
const ROLE_MAP: Record<string, string[]> = {
|
const ROLE_MAP: Record<string, string[]> = {
|
||||||
@ -14,9 +15,13 @@ const ROLE_MAP: Record<string, string[]> = {
|
|||||||
TEMP: [],
|
TEMP: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** The private docs repo name created by gitea-setup */
|
||||||
|
const DOCS_REPO_NAME = 'changemaker.lite';
|
||||||
|
|
||||||
/** Deterministic password — never exposed to users */
|
/** Deterministic password — never exposed to users */
|
||||||
function generateGiteaPassword(userId: string): string {
|
function generateGiteaPassword(userId: string): string {
|
||||||
return createHmac('sha256', env.JWT_ACCESS_SECRET)
|
const salt = env.SERVICE_PASSWORD_SALT || env.JWT_ACCESS_SECRET;
|
||||||
|
return createHmac('sha256', salt)
|
||||||
.update(`gitea:${userId}`)
|
.update(`gitea:${userId}`)
|
||||||
.digest('hex');
|
.digest('hex');
|
||||||
}
|
}
|
||||||
@ -94,6 +99,9 @@ class GiteaProvisioner implements ServiceProvisioner {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Grant docs repo access based on role
|
||||||
|
await this.syncDocsRepoAccess(giteaUser.login, user.role);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
serviceUserId: String(giteaUser.id),
|
serviceUserId: String(giteaUser.id),
|
||||||
@ -121,6 +129,9 @@ class GiteaProvisioner implements ServiceProvisioner {
|
|||||||
admin: isAdmin,
|
admin: isAdmin,
|
||||||
active: user.status === 'ACTIVE',
|
active: user.status === 'ACTIVE',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Re-evaluate docs repo access based on current role
|
||||||
|
await this.syncDocsRepoAccess(username, user.role);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deactivate(serviceUserId: string): Promise<void> {
|
async deactivate(serviceUserId: string): Promise<void> {
|
||||||
@ -140,13 +151,57 @@ class GiteaProvisioner implements ServiceProvisioner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await giteaClient.adminUpdateUser(username, { active: false });
|
await giteaClient.adminUpdateUser(username, { active: false });
|
||||||
|
|
||||||
|
// Remove docs repo collaborator access
|
||||||
|
try {
|
||||||
|
const config = await giteaClient.getConfig();
|
||||||
|
if (config.repoOwner) {
|
||||||
|
await giteaClient.removeCollaborator(config.repoOwner, DOCS_REPO_NAME, username);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore — user may not have been a collaborator
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(`Gitea provisioner: deactivated user ${username}`);
|
logger.info(`Gitea provisioner: deactivated user ${username}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAuthToken(_user: CMUser, _serviceUserId: string): Promise<string | null> {
|
async getAuthToken(_user: CMUser, _serviceUserId: string): Promise<string | null> {
|
||||||
// Gitea SSO via API tokens could be implemented here if needed for iframe embedding
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the user's collaborator status on the private docs repo matches
|
||||||
|
* their role. CONTENT_ROLES (SUPER_ADMIN, CONTENT_ADMIN) get write access;
|
||||||
|
* SUPER_ADMIN users already have admin access via the Gitea admin flag,
|
||||||
|
* but we also add them as explicit collaborators for consistency.
|
||||||
|
* Users without CONTENT_ROLES are removed as collaborators.
|
||||||
|
*/
|
||||||
|
private async syncDocsRepoAccess(username: string, role: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const config = await giteaClient.getConfig();
|
||||||
|
const repoOwner = config.repoOwner;
|
||||||
|
if (!repoOwner) return; // Setup not complete — no repo owner yet
|
||||||
|
|
||||||
|
const hasDocsAccess = CONTENT_ROLES.includes(role as UserRole);
|
||||||
|
|
||||||
|
if (hasDocsAccess) {
|
||||||
|
// SUPER_ADMIN → admin, CONTENT_ADMIN → write
|
||||||
|
const permission = role === 'SUPER_ADMIN' ? 'admin' : 'write';
|
||||||
|
await giteaClient.addCollaborator(repoOwner, DOCS_REPO_NAME, username, permission);
|
||||||
|
logger.debug(`Gitea provisioner: granted ${permission} access to ${repoOwner}/${DOCS_REPO_NAME} for ${username}`);
|
||||||
|
} else {
|
||||||
|
// Remove access if user no longer has CONTENT_ROLES
|
||||||
|
const isCollab = await giteaClient.isCollaborator(repoOwner, DOCS_REPO_NAME, username);
|
||||||
|
if (isCollab) {
|
||||||
|
await giteaClient.removeCollaborator(repoOwner, DOCS_REPO_NAME, username);
|
||||||
|
logger.debug(`Gitea provisioner: removed ${username} from ${repoOwner}/${DOCS_REPO_NAME}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Non-fatal — don't block provisioning if repo access sync fails
|
||||||
|
logger.warn(`Gitea provisioner: docs repo access sync failed for ${username}:`, err instanceof Error ? err.message : err);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const giteaProvisioner = new GiteaProvisioner();
|
export const giteaProvisioner = new GiteaProvisioner();
|
||||||
|
|||||||
@ -16,7 +16,8 @@ const ROLE_MAP: Record<string, string[]> = {
|
|||||||
|
|
||||||
/** Deterministic password — never exposed to users, only used for RC internal auth */
|
/** Deterministic password — never exposed to users, only used for RC internal auth */
|
||||||
function generateRCPassword(userId: string): string {
|
function generateRCPassword(userId: string): string {
|
||||||
return createHmac('sha256', env.JWT_ACCESS_SECRET)
|
const salt = env.SERVICE_PASSWORD_SALT || env.JWT_ACCESS_SECRET;
|
||||||
|
return createHmac('sha256', salt)
|
||||||
.update(`rc:${userId}`)
|
.update(`rc:${userId}`)
|
||||||
.digest('hex');
|
.digest('hex');
|
||||||
}
|
}
|
||||||
|
|||||||
504
api/src/types/events.ts
Normal file
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,
|
PAYMENTS_ADMIN: 4,
|
||||||
EVENTS_ADMIN: 4,
|
EVENTS_ADMIN: 4,
|
||||||
SOCIAL_ADMIN: 4,
|
SOCIAL_ADMIN: 4,
|
||||||
|
POLLS_ADMIN: 4,
|
||||||
USER: 2,
|
USER: 2,
|
||||||
TEMP: 1,
|
TEMP: 1,
|
||||||
};
|
};
|
||||||
@ -25,6 +26,7 @@ export const ADMIN_ROLES: UserRole[] = [
|
|||||||
UserRole.PAYMENTS_ADMIN,
|
UserRole.PAYMENTS_ADMIN,
|
||||||
UserRole.EVENTS_ADMIN,
|
UserRole.EVENTS_ADMIN,
|
||||||
UserRole.SOCIAL_ADMIN,
|
UserRole.SOCIAL_ADMIN,
|
||||||
|
UserRole.POLLS_ADMIN,
|
||||||
];
|
];
|
||||||
|
|
||||||
// Module-specific role groups
|
// Module-specific role groups
|
||||||
@ -38,6 +40,7 @@ export const EVENTS_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.EVENTS_A
|
|||||||
export const SOCIAL_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.SOCIAL_ADMIN];
|
export const SOCIAL_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.SOCIAL_ADMIN];
|
||||||
export const SYSTEM_ROLES: UserRole[] = [UserRole.SUPER_ADMIN];
|
export const SYSTEM_ROLES: UserRole[] = [UserRole.SUPER_ADMIN];
|
||||||
export const SCHEDULING_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN, UserRole.EVENTS_ADMIN];
|
export const SCHEDULING_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN, UserRole.EVENTS_ADMIN];
|
||||||
|
export const POLLS_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.POLLS_ADMIN, UserRole.INFLUENCE_ADMIN];
|
||||||
|
|
||||||
/** Check if the user has any of the specified roles */
|
/** Check if the user has any of the specified roles */
|
||||||
export function hasAnyRole(user: { roles?: unknown; role?: UserRole }, roles: UserRole[]): boolean {
|
export function hasAnyRole(user: { roles?: unknown; role?: UserRole }, roles: UserRole[]): boolean {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user