Compare commits
No commits in common. "91db29402cb7cddc9f9b9cd829b5c72f34e76be6" and "3de1d3fca547af86ff1b2ff60de20266c40aa269" have entirely different histories.
91db29402c
...
3de1d3fca5
@ -53,14 +53,6 @@ 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 (merged to `main`). Core platform operational with Phases 1-14 complete. See `V2_PLAN.md` for the full roadmap.
|
**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.
|
||||||
|
|
||||||
**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/main/scripts/install.sh | bash
|
curl -fsSL https://gitea.bnkops.com/admin/changemaker.lite/raw/branch/v2/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,10 +173,11 @@ Pre-built images are pulled from `gitea.bnkops.com/admin` (~2 min). Database mig
|
|||||||
|
|
||||||
### Source Install (Development)
|
### Source Install (Development)
|
||||||
|
|
||||||
1. **Clone repository:**
|
1. **Clone repository and checkout v2 branch:**
|
||||||
```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:**
|
||||||
@ -320,7 +321,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/main/scripts/install.sh | bash
|
curl -fsSL https://gitea.bnkops.com/admin/changemaker.lite/raw/branch/v2/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/main/scripts/install.sh | bash
|
curl -fsSL https://gitea.bnkops.com/admin/changemaker.lite/raw/branch/v2/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/main/scripts/install.sh | bash
|
curl -fsSL https://gitea.bnkops.com/admin/changemaker.lite/raw/branch/v2/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
|
cd changemaker.lite && git checkout v2
|
||||||
|
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# Edit .env -- set passwords, JWT secrets, admin credentials
|
# Edit .env -- set passwords, JWT secrets, admin credentials
|
||||||
|
|||||||
@ -1,161 +0,0 @@
|
|||||||
# 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.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz",
|
||||||
"integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==",
|
"integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@ -1167,9 +1167,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm64": {
|
"node_modules/@rollup/rollup-android-arm64": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz",
|
||||||
"integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==",
|
"integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -1180,9 +1180,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz",
|
||||||
"integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==",
|
"integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -1193,9 +1193,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-x64": {
|
"node_modules/@rollup/rollup-darwin-x64": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz",
|
||||||
"integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==",
|
"integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -1206,9 +1206,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz",
|
||||||
"integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==",
|
"integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -1219,9 +1219,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz",
|
||||||
"integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==",
|
"integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -1232,9 +1232,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz",
|
||||||
"integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==",
|
"integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@ -1245,9 +1245,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz",
|
||||||
"integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==",
|
"integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@ -1258,9 +1258,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz",
|
||||||
"integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==",
|
"integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -1271,9 +1271,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz",
|
||||||
"integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==",
|
"integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -1284,9 +1284,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz",
|
||||||
"integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==",
|
"integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@ -1297,9 +1297,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz",
|
||||||
"integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==",
|
"integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@ -1310,9 +1310,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz",
|
||||||
"integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==",
|
"integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@ -1323,9 +1323,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz",
|
||||||
"integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==",
|
"integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@ -1336,9 +1336,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz",
|
||||||
"integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==",
|
"integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@ -1349,9 +1349,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz",
|
||||||
"integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==",
|
"integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@ -1362,9 +1362,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz",
|
||||||
"integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==",
|
"integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@ -1375,9 +1375,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz",
|
||||||
"integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==",
|
"integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -1388,9 +1388,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz",
|
||||||
"integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==",
|
"integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -1401,9 +1401,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz",
|
||||||
"integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==",
|
"integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -1414,9 +1414,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz",
|
||||||
"integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==",
|
"integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -1427,9 +1427,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz",
|
||||||
"integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==",
|
"integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -1440,9 +1440,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz",
|
||||||
"integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==",
|
"integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@ -1453,9 +1453,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz",
|
||||||
"integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==",
|
"integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -1466,9 +1466,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||||
"version": "4.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz",
|
||||||
"integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==",
|
"integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -2261,9 +2261,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/dompurify": {
|
"node_modules/dompurify": {
|
||||||
"version": "3.3.3",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
|
||||||
"integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==",
|
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
|
||||||
"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.4",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"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.60.1",
|
"version": "4.57.1",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
|
||||||
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
|
"integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==",
|
||||||
"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.60.1",
|
"@rollup/rollup-android-arm-eabi": "4.57.1",
|
||||||
"@rollup/rollup-android-arm64": "4.60.1",
|
"@rollup/rollup-android-arm64": "4.57.1",
|
||||||
"@rollup/rollup-darwin-arm64": "4.60.1",
|
"@rollup/rollup-darwin-arm64": "4.57.1",
|
||||||
"@rollup/rollup-darwin-x64": "4.60.1",
|
"@rollup/rollup-darwin-x64": "4.57.1",
|
||||||
"@rollup/rollup-freebsd-arm64": "4.60.1",
|
"@rollup/rollup-freebsd-arm64": "4.57.1",
|
||||||
"@rollup/rollup-freebsd-x64": "4.60.1",
|
"@rollup/rollup-freebsd-x64": "4.57.1",
|
||||||
"@rollup/rollup-linux-arm-gnueabihf": "4.60.1",
|
"@rollup/rollup-linux-arm-gnueabihf": "4.57.1",
|
||||||
"@rollup/rollup-linux-arm-musleabihf": "4.60.1",
|
"@rollup/rollup-linux-arm-musleabihf": "4.57.1",
|
||||||
"@rollup/rollup-linux-arm64-gnu": "4.60.1",
|
"@rollup/rollup-linux-arm64-gnu": "4.57.1",
|
||||||
"@rollup/rollup-linux-arm64-musl": "4.60.1",
|
"@rollup/rollup-linux-arm64-musl": "4.57.1",
|
||||||
"@rollup/rollup-linux-loong64-gnu": "4.60.1",
|
"@rollup/rollup-linux-loong64-gnu": "4.57.1",
|
||||||
"@rollup/rollup-linux-loong64-musl": "4.60.1",
|
"@rollup/rollup-linux-loong64-musl": "4.57.1",
|
||||||
"@rollup/rollup-linux-ppc64-gnu": "4.60.1",
|
"@rollup/rollup-linux-ppc64-gnu": "4.57.1",
|
||||||
"@rollup/rollup-linux-ppc64-musl": "4.60.1",
|
"@rollup/rollup-linux-ppc64-musl": "4.57.1",
|
||||||
"@rollup/rollup-linux-riscv64-gnu": "4.60.1",
|
"@rollup/rollup-linux-riscv64-gnu": "4.57.1",
|
||||||
"@rollup/rollup-linux-riscv64-musl": "4.60.1",
|
"@rollup/rollup-linux-riscv64-musl": "4.57.1",
|
||||||
"@rollup/rollup-linux-s390x-gnu": "4.60.1",
|
"@rollup/rollup-linux-s390x-gnu": "4.57.1",
|
||||||
"@rollup/rollup-linux-x64-gnu": "4.60.1",
|
"@rollup/rollup-linux-x64-gnu": "4.57.1",
|
||||||
"@rollup/rollup-linux-x64-musl": "4.60.1",
|
"@rollup/rollup-linux-x64-musl": "4.57.1",
|
||||||
"@rollup/rollup-openbsd-x64": "4.60.1",
|
"@rollup/rollup-openbsd-x64": "4.57.1",
|
||||||
"@rollup/rollup-openharmony-arm64": "4.60.1",
|
"@rollup/rollup-openharmony-arm64": "4.57.1",
|
||||||
"@rollup/rollup-win32-arm64-msvc": "4.60.1",
|
"@rollup/rollup-win32-arm64-msvc": "4.57.1",
|
||||||
"@rollup/rollup-win32-ia32-msvc": "4.60.1",
|
"@rollup/rollup-win32-ia32-msvc": "4.57.1",
|
||||||
"@rollup/rollup-win32-x64-gnu": "4.60.1",
|
"@rollup/rollup-win32-x64-gnu": "4.57.1",
|
||||||
"@rollup/rollup-win32-x64-msvc": "4.60.1",
|
"@rollup/rollup-win32-x64-msvc": "4.57.1",
|
||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -3993,9 +3993,10 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/yaml": {
|
"node_modules/yaml": {
|
||||||
"version": "2.8.3",
|
"version": "2.8.2",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||||
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||||
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
"yaml": "bin.mjs"
|
"yaml": "bin.mjs"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -112,7 +112,6 @@ 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';
|
||||||
@ -133,7 +132,6 @@ 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';
|
||||||
@ -144,8 +142,6 @@ 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';
|
||||||
@ -280,14 +276,6 @@ 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 />} />
|
||||||
@ -574,14 +562,6 @@ 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,7 +71,6 @@ 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';
|
||||||
@ -188,7 +187,6 @@ 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' }] : []),
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -714,7 +712,6 @@ 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),
|
||||||
@ -722,7 +719,6 @@ 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,11 +22,10 @@ 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' | 'enablePolls'>;
|
feature: keyof Pick<SiteSettings, 'enableInfluence' | 'enableMap' | 'enableLandingPages' | 'enableNewsletter' | 'enableMediaFeatures' | 'enablePayments' | 'enableGalleryAds' | 'enablePeople' | 'enableEvents' | 'enableSocial' | 'enableMeet' | 'enableMeetingPlanner' | 'enableTicketedEvents' | 'enableSocialCalendar'>;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -571,40 +571,6 @@ 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,7 +16,6 @@ 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',
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,51 +0,0 @@
|
|||||||
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,6 +59,7 @@ import {
|
|||||||
MobileOutlined,
|
MobileOutlined,
|
||||||
DesktopOutlined,
|
DesktopOutlined,
|
||||||
CalendarOutlined,
|
CalendarOutlined,
|
||||||
|
ClearOutlined,
|
||||||
FormOutlined,
|
FormOutlined,
|
||||||
ShareAltOutlined,
|
ShareAltOutlined,
|
||||||
LockOutlined,
|
LockOutlined,
|
||||||
@ -590,6 +591,40 @@ 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);
|
||||||
@ -765,10 +800,9 @@ export default function DocsPage() {
|
|||||||
}
|
}
|
||||||
}, [fileContentCache, messageApi]);
|
}, [fileContentCache, messageApi]);
|
||||||
|
|
||||||
// Handle navigation state from command palette or metadata page — auto-select a file
|
// Handle navigation state from command palette — auto-select a file
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const state = location.state as { selectFile?: string; openFile?: string } | null;
|
const selectFile = (location.state as { selectFile?: string } | null)?.selectFile;
|
||||||
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
|
||||||
@ -821,22 +855,8 @@ 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, explicitly save current content + refresh preview
|
// In collab mode, auto-save handles persistence — just refresh preview
|
||||||
if (selectedFile && collab.yText) {
|
previewIframeRef.current?.contentWindow?.location.reload();
|
||||||
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();
|
||||||
}
|
}
|
||||||
@ -1583,10 +1603,13 @@ 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]);
|
), [layout, dirty, saving, saveFile, refreshPreview, mkdocsDirectUrl, token.colorBorderSecondary, isSuperAdmin, building, confirmAndBuild, resetting, confirmAndReset]);
|
||||||
|
|
||||||
// Inject header
|
// Inject header
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -912,49 +912,6 @@ 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,9 +468,6 @@ 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,7 +81,6 @@ 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',
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,412 +0,0 @@
|
|||||||
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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,293 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,63 +0,0 @@
|
|||||||
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 --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="Clone the SMS server (first time only)" cmd="pkg install -y git && git clone https://gitea.bnkops.com/admin/campaign_connector.git ~/sms-server" />
|
||||||
<CmdLine comment="Run the setup script with your API key" cmd={`bash ~/sms-server/termux-sms/setup.sh ${generatedKey}`} />
|
<CmdLine comment="Run the setup script with your API key" cmd={`bash ~/sms-server/android/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 termux-sms/setup-services.sh` }}>cd ~/sms-server && git pull && bash termux-sms/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 android/setup-services.sh` }}>cd ~/sms-server && git pull && bash android/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' | 'POLLS_ADMIN' | 'USER' | 'TEMP';
|
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 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', 'POLLS_ADMIN'];
|
export const ADMIN_ROLES: UserRole[] = ['SUPER_ADMIN', 'INFLUENCE_ADMIN', 'MAP_ADMIN', 'BROADCAST_ADMIN', 'CONTENT_ADMIN', 'MEDIA_ADMIN', 'PAYMENTS_ADMIN', 'EVENTS_ADMIN', 'SOCIAL_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,7 +114,6 @@ 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 ---
|
||||||
|
|
||||||
@ -1170,7 +1169,6 @@ 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;
|
||||||
@ -3358,98 +3356,3 @@ 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,6 +1656,14 @@
|
|||||||
"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",
|
||||||
@ -1893,7 +1901,6 @@
|
|||||||
"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": "*"
|
||||||
}
|
}
|
||||||
@ -2095,9 +2102,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ajv": {
|
"node_modules/ajv": {
|
||||||
"version": "8.18.0",
|
"version": "8.17.1",
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||||
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-deep-equal": "^3.1.3",
|
"fast-deep-equal": "^3.1.3",
|
||||||
"fast-uri": "^3.0.1",
|
"fast-uri": "^3.0.1",
|
||||||
@ -2209,11 +2216,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/balanced-match": {
|
"node_modules/balanced-match": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.2.tgz",
|
||||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
"integrity": "sha512-x0K50QvKQ97fdEz2kPehIerj+YTeptKF9hyYkKf6egnwmMWAkADiO0QCzSp0R5xN8FTZgYaBfSaue46Ej62nMg==",
|
||||||
|
"dependencies": {
|
||||||
|
"jackspeak": "^4.2.3"
|
||||||
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "18 || 20 || >=22"
|
"node": "20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/bcryptjs": {
|
"node_modules/bcryptjs": {
|
||||||
@ -2250,14 +2260,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "5.0.5",
|
"version": "5.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz",
|
||||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
"integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^4.0.2"
|
"balanced-match": "^4.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "18 || 20 || >=22"
|
"node": "20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/buffer-equal-constant-time": {
|
"node_modules/buffer-equal-constant-time": {
|
||||||
@ -2538,7 +2548,6 @@
|
|||||||
"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"
|
||||||
@ -2691,15 +2700,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/drizzle-kit": {
|
"node_modules/drizzle-kit": {
|
||||||
"version": "0.31.10",
|
"version": "0.31.9",
|
||||||
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.10.tgz",
|
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.31.9.tgz",
|
||||||
"integrity": "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==",
|
"integrity": "sha512-GViD3IgsXn7trFyBUUHyTFBpH/FsHTxYJ66qdbVggxef4UBPHRYxQaRzYLTuekYnk9i5FIEL9pbBIwMqX/Uwrg==",
|
||||||
"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",
|
||||||
"tsx": "^4.21.0"
|
"esbuild-register": "^3.5.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"drizzle-kit": "bin.cjs"
|
"drizzle-kit": "bin.cjs"
|
||||||
@ -3417,6 +3426,41 @@
|
|||||||
"@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",
|
||||||
@ -3579,9 +3623,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/fastify": {
|
"node_modules/fastify": {
|
||||||
"version": "5.8.4",
|
"version": "5.7.4",
|
||||||
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.4.tgz",
|
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.7.4.tgz",
|
||||||
"integrity": "sha512-sa42J1xylbBAYUWALSBoyXKPDUvM3OoNOibIefA+Oha57FryXKKCZarA1iDntOCWp3O35voZLuDg2mdODXtPzQ==",
|
"integrity": "sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@ -3602,7 +3646,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": "^9.14.0 || ^10.1.0",
|
"pino": "^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",
|
||||||
@ -4022,6 +4066,20 @@
|
|||||||
"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",
|
||||||
@ -4349,14 +4407,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "10.2.5",
|
"version": "10.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.0.tgz",
|
||||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
"integrity": "sha512-ugkC31VaVg9cF0DFVoADH12k6061zNZkZON+aX8AWsR9GhPcErkcMBceb6znR8wLERM2AkkOxy2nWRLpT9Jq5w==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"brace-expansion": "^5.0.5"
|
"brace-expansion": "^5.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "18 || 20 || >=22"
|
"node": "20 || >=22"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
@ -4484,9 +4542,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nodemailer": {
|
"node_modules/nodemailer": {
|
||||||
"version": "8.0.4",
|
"version": "8.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz",
|
||||||
"integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==",
|
"integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
@ -4647,9 +4705,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/path-to-regexp": {
|
"node_modules/path-to-regexp": {
|
||||||
"version": "0.1.13",
|
"version": "0.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
|
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||||
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="
|
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="
|
||||||
},
|
},
|
||||||
"node_modules/pathe": {
|
"node_modules/pathe": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
@ -4942,9 +5000,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.14.2",
|
"version": "6.14.1",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
|
||||||
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
|
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"side-channel": "^1.1.0"
|
"side-channel": "^1.1.0"
|
||||||
},
|
},
|
||||||
@ -5790,9 +5848,10 @@
|
|||||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="
|
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="
|
||||||
},
|
},
|
||||||
"node_modules/yaml": {
|
"node_modules/yaml": {
|
||||||
"version": "2.8.3",
|
"version": "2.8.2",
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
|
||||||
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
|
||||||
|
"license": "ISC",
|
||||||
"bin": {
|
"bin": {
|
||||||
"yaml": "bin.mjs"
|
"yaml": "bin.mjs"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,168 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
-- 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;
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
-- 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,7 +21,6 @@ enum UserRole {
|
|||||||
PAYMENTS_ADMIN
|
PAYMENTS_ADMIN
|
||||||
EVENTS_ADMIN
|
EVENTS_ADMIN
|
||||||
SOCIAL_ADMIN
|
SOCIAL_ADMIN
|
||||||
POLLS_ADMIN
|
|
||||||
USER
|
USER
|
||||||
TEMP
|
TEMP
|
||||||
}
|
}
|
||||||
@ -168,13 +167,6 @@ 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")
|
||||||
|
|
||||||
@ -970,7 +962,6 @@ 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")
|
||||||
@ -1537,7 +1528,6 @@ enum OrderStatus {
|
|||||||
COMPLETED
|
COMPLETED
|
||||||
FAILED
|
FAILED
|
||||||
REFUNDED
|
REFUNDED
|
||||||
DISPUTED
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum NotificationType {
|
enum NotificationType {
|
||||||
@ -1562,10 +1552,6 @@ 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@ -3441,8 +3427,7 @@ 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")
|
||||||
@ -3451,12 +3436,10 @@ 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")
|
||||||
@ -3522,7 +3505,6 @@ 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")
|
||||||
@ -4292,7 +4274,6 @@ 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])
|
||||||
@ -4968,9 +4949,6 @@ enum CalendarItemSource {
|
|||||||
MANUAL
|
MANUAL
|
||||||
ICS_FEED
|
ICS_FEED
|
||||||
POLL
|
POLL
|
||||||
SHIFT
|
|
||||||
MEETING
|
|
||||||
TICKETED_EVENT
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum CalendarRecurrenceFrequency {
|
enum CalendarRecurrenceFrequency {
|
||||||
@ -5366,132 +5344,3 @@ 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,32 +466,6 @@ 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,11 +38,6 @@ 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,23 +156,6 @@ 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,
|
||||||
@ -190,23 +173,6 @@ 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,7 +1,6 @@
|
|||||||
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';
|
||||||
@ -23,9 +22,6 @@ 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). */
|
||||||
@ -49,53 +45,6 @@ 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',
|
||||||
@ -106,8 +55,6 @@ 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) {
|
||||||
@ -334,10 +281,6 @@ 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) {
|
||||||
@ -359,11 +302,9 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,50 +0,0 @@
|
|||||||
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 } } },
|
include: { user: { select: { id: true, name: true, email: true } } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -972,7 +972,7 @@ export const sharedCalendarService = {
|
|||||||
orderBy: [{ date: 'asc' }, { startTime: 'asc' }],
|
orderBy: [{ date: 'asc' }, { startTime: 'asc' }],
|
||||||
});
|
});
|
||||||
|
|
||||||
const memberName = member.user.name || 'Member';
|
const memberName = member.user.name || member.user.email;
|
||||||
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';
|
const memberName = member.user.name || member.user.email;
|
||||||
for (const si of sysItems) {
|
for (const si of sysItems) {
|
||||||
allItems.push({
|
allItems.push({
|
||||||
...si,
|
...si,
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
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();
|
||||||
|
|
||||||
@ -14,17 +13,4 @@ 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,7 +5,6 @@ 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();
|
||||||
|
|
||||||
@ -46,22 +45,7 @@ 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,7 +5,6 @@ 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();
|
||||||
|
|
||||||
@ -48,12 +47,6 @@ 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);
|
||||||
@ -69,12 +62,6 @@ 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);
|
||||||
@ -88,13 +75,7 @@ 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 { eventBus } from '../../../services/event-bus.service';
|
import { rocketchatWebhookService } from '../../../services/rocketchat-webhook.service';
|
||||||
import type {
|
import type {
|
||||||
SubmitResponseInput,
|
SubmitResponseInput,
|
||||||
ListPublicResponsesInput,
|
ListPublicResponsesInput,
|
||||||
@ -102,14 +102,11 @@ export const responsesService = {
|
|||||||
logger.error('Failed to enqueue response submitted notification:', err);
|
logger.error('Failed to enqueue response submitted notification:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publish response submitted event
|
// Notify Rocket.Chat
|
||||||
eventBus.publish('response.submitted', {
|
rocketchatWebhookService.onCampaignResponseSubmitted({
|
||||||
responseId: response.id,
|
|
||||||
campaignId: campaign.id,
|
|
||||||
campaignTitle: campaign.title,
|
campaignTitle: campaign.title,
|
||||||
representativeName: data.representativeName,
|
representativeName: data.representativeName,
|
||||||
userEmail: data.submittedByEmail,
|
}).catch(() => {});
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: response.id,
|
id: response.id,
|
||||||
|
|||||||
@ -9,7 +9,6 @@ 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();
|
||||||
|
|
||||||
@ -173,14 +172,6 @@ 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);
|
||||||
@ -235,12 +226,6 @@ 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,7 +2,6 @@ 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();
|
||||||
|
|
||||||
@ -33,13 +32,6 @@ 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 },
|
||||||
@ -66,56 +58,6 @@ 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,7 +11,8 @@ 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 { eventBus } from '../../../services/event-bus.service';
|
import { rocketchatWebhookService } from '../../../services/rocketchat-webhook.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,
|
||||||
@ -253,6 +254,20 @@ 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')) {
|
||||||
@ -300,7 +315,7 @@ export const canvassService = {
|
|||||||
logger.error('Failed to enqueue session summary notification:', err);
|
logger.error('Failed to enqueue session summary notification:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publish canvass session completed event (consumed by RC, Listmonk, etc.)
|
// Listmonk event sync — add canvasser to subscribers
|
||||||
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 } }),
|
||||||
@ -313,15 +328,13 @@ export const canvassService = {
|
|||||||
for (const row of syncOutcomes) {
|
for (const row of syncOutcomes) {
|
||||||
outcomes[row.outcome] = row._count;
|
outcomes[row.outcome] = row._count;
|
||||||
}
|
}
|
||||||
eventBus.publish('canvass.session.completed', {
|
listmonkEventSyncService.onCanvassSessionCompleted({
|
||||||
sessionId,
|
email: syncUser.email,
|
||||||
userId,
|
name: syncUser.name || syncUser.email,
|
||||||
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 */ }
|
||||||
|
|
||||||
@ -637,16 +650,16 @@ export const canvassService = {
|
|||||||
include: { location: { select: { address: true } } },
|
include: { location: { select: { address: true } } },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Publish address updated event (consumed by Listmonk, etc.)
|
// Sync support level change to Listmonk (fire-and-forget)
|
||||||
if (updatedAddress.email) {
|
if (updatedAddress.email) {
|
||||||
const name = [updatedAddress.firstName, updatedAddress.lastName].filter(Boolean).join(' ');
|
const name = [updatedAddress.firstName, updatedAddress.lastName].filter(Boolean).join(' ');
|
||||||
eventBus.publish('contact.address.updated', {
|
listmonkEventSyncService.onAddressUpdated({
|
||||||
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,7 +8,9 @@ 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 { eventBus } from '../../../services/event-bus.service';
|
import { rocketchatWebhookService } from '../../../services/rocketchat-webhook.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';
|
||||||
@ -136,17 +138,26 @@ export const shiftsService = {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Publish shift.created event (listeners: Gancio, Calendar, n8n)
|
// Gancio event sync (fire-and-forget)
|
||||||
eventBus.publish('shift.created', {
|
if (gancioClient.enabled) {
|
||||||
shiftId: shift.id,
|
gancioClient.createEvent({
|
||||||
title: shift.title,
|
title: shift.title,
|
||||||
date: new Date(shift.date).toISOString().split('T')[0],
|
description: shift.description,
|
||||||
|
location: shift.location,
|
||||||
|
date: shift.date,
|
||||||
startTime: shift.startTime,
|
startTime: shift.startTime,
|
||||||
endTime: shift.endTime,
|
endTime: shift.endTime,
|
||||||
cutId: shift.cutId,
|
}).then(async (eventId) => {
|
||||||
cutName: null,
|
if (eventId) {
|
||||||
createdByUserId: userId,
|
await prisma.shift.update({
|
||||||
|
where: { id: shift.id },
|
||||||
|
data: { gancioEventId: eventId },
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
}).catch((err) => {
|
||||||
|
logger.warn('Gancio sync on shift create failed:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Bust unified calendar cache
|
// Bust unified calendar cache
|
||||||
unifiedCalendarService.bustCache().catch(() => {});
|
unifiedCalendarService.bustCache().catch(() => {});
|
||||||
@ -180,17 +191,19 @@ export const shiftsService = {
|
|||||||
data: updateData,
|
data: updateData,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Publish shift.updated event (listeners: Gancio, Calendar, n8n)
|
// Gancio event sync (fire-and-forget)
|
||||||
eventBus.publish('shift.updated', {
|
if (gancioClient.enabled && shift.gancioEventId) {
|
||||||
shiftId: shift.id,
|
gancioClient.updateEvent(shift.gancioEventId, {
|
||||||
title: shift.title,
|
title: shift.title,
|
||||||
date: new Date(shift.date).toISOString().split('T')[0],
|
description: shift.description,
|
||||||
|
location: shift.location,
|
||||||
|
date: shift.date,
|
||||||
startTime: shift.startTime,
|
startTime: shift.startTime,
|
||||||
endTime: shift.endTime,
|
endTime: shift.endTime,
|
||||||
cutId: shift.cutId,
|
}).catch((err) => {
|
||||||
cutName: null,
|
logger.warn('Gancio sync on shift update failed:', err);
|
||||||
changes: Object.keys(data),
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Bust unified calendar cache
|
// Bust unified calendar cache
|
||||||
unifiedCalendarService.bustCache().catch(() => {});
|
unifiedCalendarService.bustCache().catch(() => {});
|
||||||
@ -204,12 +217,12 @@ export const shiftsService = {
|
|||||||
throw new AppError(404, 'Shift not found', 'SHIFT_NOT_FOUND');
|
throw new AppError(404, 'Shift not found', 'SHIFT_NOT_FOUND');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publish shift.deleted event (listeners: Gancio, Calendar, n8n)
|
// Delete Gancio event before deleting shift (fire-and-forget)
|
||||||
eventBus.publish('shift.deleted', {
|
if (gancioClient.enabled && existing.gancioEventId) {
|
||||||
shiftId: id,
|
gancioClient.deleteEvent(existing.gancioEventId).catch((err) => {
|
||||||
title: existing.title,
|
logger.warn('Gancio sync on shift delete failed:', err);
|
||||||
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) {
|
||||||
@ -346,17 +359,13 @@ export const shiftsService = {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Publish shift.signup.created event (listeners: Listmonk, RC, CRM, n8n)
|
// Listmonk event sync
|
||||||
eventBus.publish('shift.signup.created', {
|
listmonkEventSyncService.onShiftSignup({
|
||||||
shiftId,
|
email: data.userEmail,
|
||||||
|
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],
|
||||||
userName: data.userName || data.userEmail,
|
}).catch(() => {});
|
||||||
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(() => {});
|
||||||
@ -542,6 +551,14 @@ 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')) {
|
||||||
@ -634,17 +651,13 @@ export const shiftsService = {
|
|||||||
|
|
||||||
recordShiftSignup();
|
recordShiftSignup();
|
||||||
|
|
||||||
// Publish shift.signup.created event (listeners: Listmonk, RC, CRM, n8n)
|
// Listmonk event sync
|
||||||
eventBus.publish('shift.signup.created', {
|
listmonkEventSyncService.onShiftSignup({
|
||||||
shiftId,
|
email: data.email,
|
||||||
|
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],
|
||||||
userName: data.name || data.email,
|
}).catch(() => {});
|
||||||
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(() => {});
|
||||||
@ -720,16 +733,14 @@ export const shiftsService = {
|
|||||||
logger.error('Failed to enqueue cancellation notification:', err);
|
logger.error('Failed to enqueue cancellation notification:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publish shift.signup.cancelled event (listeners: RC, n8n)
|
// Notify Rocket.Chat of cancellation
|
||||||
if (shift) {
|
if (shift) {
|
||||||
eventBus.publish('shift.signup.cancelled', {
|
const shiftDateStr = new Date(shift.date).toLocaleDateString('en-CA', { month: 'short', day: 'numeric' });
|
||||||
shiftId,
|
rocketchatWebhookService.onShiftCancellation({
|
||||||
shiftTitle: shift.title,
|
|
||||||
shiftDate: new Date(shift.date).toISOString().split('T')[0],
|
|
||||||
userName: signup.userName || userEmail,
|
userName: signup.userName || userEmail,
|
||||||
userEmail,
|
shiftTitle: shift.title,
|
||||||
signupType: 'public',
|
shiftDate: shiftDateStr,
|
||||||
});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notification: admin shift cancellation alert
|
// Notification: admin shift cancellation alert
|
||||||
@ -885,6 +896,14 @@ 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')) {
|
||||||
@ -961,17 +980,13 @@ export const shiftsService = {
|
|||||||
logger.error('Failed to schedule shift thank-you:', err);
|
logger.error('Failed to schedule shift thank-you:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publish shift.signup.created event (listeners: Listmonk, RC, CRM, n8n)
|
// Listmonk event sync
|
||||||
eventBus.publish('shift.signup.created', {
|
listmonkEventSyncService.onShiftSignup({
|
||||||
shiftId,
|
email: user.email,
|
||||||
|
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],
|
||||||
userName: user.name || user.email,
|
}).catch(() => {});
|
||||||
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(() => {});
|
||||||
@ -1045,16 +1060,14 @@ export const shiftsService = {
|
|||||||
logger.error('Failed to enqueue cancellation notification:', err);
|
logger.error('Failed to enqueue cancellation notification:', err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Publish shift.signup.cancelled event (listeners: RC, n8n)
|
// Notify Rocket.Chat of cancellation
|
||||||
if (shift) {
|
if (shift) {
|
||||||
eventBus.publish('shift.signup.cancelled', {
|
const shiftDateStr = new Date(shift.date).toLocaleDateString('en-CA', { month: 'short', day: 'numeric' });
|
||||||
shiftId,
|
rocketchatWebhookService.onShiftCancellation({
|
||||||
shiftTitle: shift.title,
|
|
||||||
shiftDate: new Date(shift.date).toISOString().split('T')[0],
|
|
||||||
userName: user.name || user.email,
|
userName: user.name || user.email,
|
||||||
userEmail: user.email,
|
shiftTitle: shift.title,
|
||||||
signupType: 'volunteer',
|
shiftDate: shiftDateStr,
|
||||||
});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notification: admin shift cancellation alert
|
// Notification: admin shift cancellation alert
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
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';
|
||||||
@ -182,25 +181,19 @@ export async function photosPublicRoutes(fastify: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!filePath) {
|
if (!filePath || filePath.includes('..')) {
|
||||||
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(resolvedPath);
|
await access(filePath);
|
||||||
} 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(resolvedPath));
|
return reply.send(createReadStream(filePath));
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -215,25 +208,19 @@ export async function photosPublicRoutes(fastify: FastifyInstance) {
|
|||||||
select: { thumbnailPath: true },
|
select: { thumbnailPath: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!photo?.thumbnailPath) {
|
if (!photo?.thumbnailPath || photo.thumbnailPath.includes('..')) {
|
||||||
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(resolvedThumb);
|
await access(photo.thumbnailPath);
|
||||||
} 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(resolvedThumb));
|
return reply.send(createReadStream(photo.thumbnailPath));
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,6 @@ 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({
|
||||||
@ -63,13 +62,6 @@ 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,7 +7,6 @@ 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 {
|
||||||
@ -207,15 +206,7 @@ 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);
|
||||||
@ -242,12 +233,6 @@ 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,8 +8,7 @@ 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, hasAnyRole } from '../../utils/roles';
|
import { EVENTS_ROLES } from '../../utils/roles';
|
||||||
import { AppError } from '../../middleware/error-handler';
|
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@ -45,14 +44,6 @@ 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); }
|
||||||
});
|
});
|
||||||
@ -65,19 +56,10 @@ router.post('/', authenticate, requireRole(...EVENTS_ROLES), validate(createActi
|
|||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update action item — admins, assignees, or creators can update
|
// Update action item (authenticate only - assignees can update their own)
|
||||||
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,24 +221,4 @@ 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,7 +1,5 @@
|
|||||||
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';
|
||||||
@ -31,8 +29,6 @@ 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).max(10000000), // max $100,000
|
amountCents: z.number().int().min(100),
|
||||||
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,18 +141,10 @@ export const donationsService = {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Stripe refund succeeded — update DB. If this fails, the charge.refunded
|
const updated = await prisma.order.update({
|
||||||
// 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,
|
||||||
@ -195,6 +187,8 @@ 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,4 +1,3 @@
|
|||||||
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';
|
||||||
@ -41,16 +40,10 @@ 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, skipping masked sentinel values from the admin UI
|
// Encrypt sensitive fields
|
||||||
for (const field of ENCRYPTED_FIELDS) {
|
for (const field of ENCRYPTED_FIELDS) {
|
||||||
if (field in toWrite && typeof toWrite[field] === 'string') {
|
if (field in toWrite && typeof toWrite[field] === 'string' && toWrite[field]) {
|
||||||
const val = toWrite[field] as string;
|
toWrite[field] = encrypt(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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -76,17 +69,3 @@ 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,7 +37,6 @@ 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);
|
||||||
@ -51,14 +50,7 @@ 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);
|
||||||
// Mask secrets in response (same as GET) to prevent leaking decrypted keys
|
res.json(settings);
|
||||||
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,13 +1,12 @@
|
|||||||
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, requirePaymentsEnabled } from './payment-settings.service';
|
import { paymentSettingsService } 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,
|
||||||
@ -86,8 +85,6 @@ 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) => {
|
||||||
@ -108,8 +105,6 @@ 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 {
|
||||||
@ -127,8 +122,6 @@ 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).max(10000000), // max $100,000
|
amountCents: z.number().int().min(100),
|
||||||
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', 'DISPUTED']).optional(),
|
status: z.enum(['PENDING', 'COMPLETED', 'FAILED', 'REFUNDED']).optional(),
|
||||||
type: z.enum(['product', 'donation']).optional(),
|
type: z.enum(['product', 'donation']).optional(),
|
||||||
search: z.string().optional(),
|
search: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -228,16 +228,12 @@ 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');
|
||||||
|
|
||||||
// Atomic availability check to prevent overselling under concurrency
|
if (product.maxPurchases && product.purchaseCount >= product.maxPurchases) {
|
||||||
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',
|
||||||
@ -371,16 +367,9 @@ export const productsService = {
|
|||||||
await stripe.refunds.create({ payment_intent: order.stripePaymentIntentId });
|
await stripe.refunds.create({ payment_intent: order.stripePaymentIntentId });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stripe refund succeeded — update DB. If this fails, the charge.refunded
|
return prisma.order.update({
|
||||||
// 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,6 +259,8 @@ 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 { eventBus } from '../../services/event-bus.service';
|
import { listmonkEventSyncService } from '../../services/listmonk-event-sync.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,12 +48,6 @@ 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;
|
||||||
@ -148,12 +142,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) {
|
||||||
eventBus.publish('payment.subscription.activated', {
|
listmonkEventSyncService.onSubscriptionActivated({
|
||||||
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(() => {});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -213,13 +207,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) {
|
||||||
eventBus.publish('payment.product.purchased', {
|
listmonkEventSyncService.onProductPurchased({
|
||||||
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)
|
||||||
@ -288,12 +282,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) {
|
||||||
eventBus.publish('payment.donation.completed', {
|
listmonkEventSyncService.onDonationCompleted({
|
||||||
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)
|
||||||
@ -524,103 +518,17 @@ export const webhookService = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
|
|
||||||
async handleDisputeCreated(dispute: Stripe.Dispute) {
|
// Check payments
|
||||||
const paymentIntentId = typeof dispute.payment_intent === 'string'
|
const payment = await prisma.payment.findFirst({
|
||||||
? 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 (!order || order.status === 'DISPUTED') return;
|
if (payment && payment.status !== 'refunded') {
|
||||||
|
await prisma.payment.update({
|
||||||
const previousStatus = order.status;
|
where: { id: payment.id },
|
||||||
await prisma.order.update({
|
data: { status: 'refunded' },
|
||||||
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) {
|
||||||
@ -654,19 +562,8 @@ 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,7 +2,6 @@ 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,
|
||||||
@ -1054,12 +1053,13 @@ export const peopleService = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (input.email) {
|
if (input.email) {
|
||||||
eventBus.publish('contact.tags.changed', {
|
import('../../services/listmonk-event-sync.service').then(({ listmonkEventSyncService }) => {
|
||||||
|
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,12 +1133,13 @@ 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) {
|
||||||
eventBus.publish('contact.tags.changed', {
|
import('../../services/listmonk-event-sync.service').then(({ listmonkEventSyncService }) => {
|
||||||
|
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));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1357,12 +1358,13 @@ export const peopleService = {
|
|||||||
|
|
||||||
const mergedEmail = target.email || sourceContact?.email;
|
const mergedEmail = target.email || sourceContact?.email;
|
||||||
if (mergedEmail) {
|
if (mergedEmail) {
|
||||||
eventBus.publish('contact.tags.changed', {
|
import('../../services/listmonk-event-sync.service').then(({ listmonkEventSyncService }) => {
|
||||||
|
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));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,123 +0,0 @@
|
|||||||
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 };
|
|
||||||
@ -1,112 +0,0 @@
|
|||||||
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();
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
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 };
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
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',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@ -1,121 +0,0 @@
|
|||||||
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 };
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
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>;
|
|
||||||
@ -1,656 +0,0 @@
|
|||||||
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,7 +58,6 @@ 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,7 +5,6 @@ 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();
|
||||||
@ -67,17 +66,7 @@ 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,11 +171,6 @@ 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,14 +1,9 @@
|
|||||||
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));
|
||||||
@ -37,30 +32,14 @@ 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', smsSendRateLimit, async (req, res, next) => {
|
router.post('/send', 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: { message: 'Phone and message are required', code: 'VALIDATION_ERROR' } });
|
res.status(400).json({ error: 'Phone and message are required' });
|
||||||
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' | 'poll_voted';
|
type: 'shift_signup' | 'campaign_email' | 'canvass_session' | 'response_submitted' | 'impact_story' | 'volunteer_featured' | 'referral_completed' | 'challenge_completed';
|
||||||
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, pollVotes] = await Promise.all([
|
const [shiftSignups, campaignEmails, canvassSessions, responses, impactStories, spotlights, referrals, challenges] = 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,7 +65,6 @@ 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
|
||||||
@ -78,7 +77,6 @@ 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
|
||||||
@ -364,34 +362,4 @@ 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,7 +3,6 @@ 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();
|
||||||
|
|
||||||
@ -43,14 +42,6 @@ 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,18 +31,6 @@ 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,38 +99,6 @@ 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,10 +25,6 @@ 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,30 +39,6 @@ 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);
|
||||||
|
|
||||||
@ -97,7 +73,7 @@ router.post('/', validate(createEventSchema), async (req: Request, res: Response
|
|||||||
});
|
});
|
||||||
|
|
||||||
// GET /:id — event detail
|
// GET /:id — event detail
|
||||||
router.get('/:id', requireEventOwnership, async (req: Request, res: Response, next: NextFunction) => {
|
router.get('/:id', 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);
|
||||||
@ -168,7 +144,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', requireEventOwnership, async (req: Request, res: Response, next: NextFunction) => {
|
router.post('/:id/meeting-token', 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 },
|
||||||
@ -183,7 +159,7 @@ router.post('/:id/meeting-token', requireEventOwnership, async (req: Request, re
|
|||||||
// --- Tiers ---
|
// --- Tiers ---
|
||||||
|
|
||||||
// POST /:id/tiers
|
// POST /:id/tiers
|
||||||
router.post('/:id/tiers', requireEventOwnership, validate(createTierSchema), async (req: Request, res: Response, next: NextFunction) => {
|
router.post('/:id/tiers', 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);
|
||||||
@ -191,7 +167,7 @@ router.post('/:id/tiers', requireEventOwnership, validate(createTierSchema), asy
|
|||||||
});
|
});
|
||||||
|
|
||||||
// PUT /:id/tiers/:tierId
|
// PUT /:id/tiers/:tierId
|
||||||
router.put('/:id/tiers/:tierId', requireEventOwnership, validate(updateTierSchema), async (req: Request, res: Response, next: NextFunction) => {
|
router.put('/:id/tiers/:tierId', 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,
|
||||||
@ -203,7 +179,7 @@ router.put('/:id/tiers/:tierId', requireEventOwnership, validate(updateTierSchem
|
|||||||
});
|
});
|
||||||
|
|
||||||
// DELETE /:id/tiers/:tierId
|
// DELETE /:id/tiers/:tierId
|
||||||
router.delete('/:id/tiers/:tierId', requireEventOwnership, async (req: Request, res: Response, next: NextFunction) => {
|
router.delete('/:id/tiers/:tierId', 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 });
|
||||||
@ -213,7 +189,7 @@ router.delete('/:id/tiers/:tierId', requireEventOwnership, async (req: Request,
|
|||||||
// --- Tickets ---
|
// --- Tickets ---
|
||||||
|
|
||||||
// GET /:id/tickets
|
// GET /:id/tickets
|
||||||
router.get('/:id/tickets', requireEventOwnership, async (req: Request, res: Response, next: NextFunction) => {
|
router.get('/:id/tickets', 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);
|
||||||
@ -225,7 +201,7 @@ router.get('/:id/tickets', requireEventOwnership, async (req: Request, res: Resp
|
|||||||
});
|
});
|
||||||
|
|
||||||
// GET /:id/checkins
|
// GET /:id/checkins
|
||||||
router.get('/:id/checkins', requireEventOwnership, async (req: Request, res: Response, next: NextFunction) => {
|
router.get('/:id/checkins', 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);
|
||||||
@ -235,7 +211,7 @@ router.get('/:id/checkins', requireEventOwnership, async (req: Request, res: Res
|
|||||||
});
|
});
|
||||||
|
|
||||||
// GET /:id/stats
|
// GET /:id/stats
|
||||||
router.get('/:id/stats', requireEventOwnership, async (req: Request, res: Response, next: NextFunction) => {
|
router.get('/:id/stats', 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);
|
||||||
@ -243,7 +219,7 @@ router.get('/:id/stats', requireEventOwnership, async (req: Request, res: Respon
|
|||||||
});
|
});
|
||||||
|
|
||||||
// POST /:id/resend-ticket/:ticketId
|
// POST /:id/resend-ticket/:ticketId
|
||||||
router.post('/:id/resend-ticket/:ticketId', requireEventOwnership, async (req: Request, res: Response, next: NextFunction) => {
|
router.post('/:id/resend-ticket/:ticketId', 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 },
|
||||||
@ -297,7 +273,7 @@ router.post('/:id/resend-ticket/:ticketId', requireEventOwnership, async (req: R
|
|||||||
});
|
});
|
||||||
|
|
||||||
// POST /:id/tickets/:ticketId/cancel
|
// POST /:id/tickets/:ticketId/cancel
|
||||||
router.post('/:id/tickets/:ticketId/cancel', requireEventOwnership, async (req: Request, res: Response, next: NextFunction) => {
|
router.post('/:id/tickets/:ticketId/cancel', 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,8 +9,6 @@ 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();
|
||||||
|
|
||||||
@ -103,7 +101,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', requirePaymentsEnabled, paymentCheckoutRateLimit, optionalAuth, validate(checkoutSchema), async (req: Request, res: Response, next: NextFunction) => {
|
router.post('/:slug/checkout', 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,5 +1,6 @@
|
|||||||
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';
|
||||||
@ -8,7 +9,6 @@ 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,19 +384,10 @@ export const ticketedEventsService = {
|
|||||||
include: { ticketTiers: true },
|
include: { ticketTiers: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calendar cache bust (fire-and-forget)
|
// Gancio sync + 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;
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -413,18 +404,8 @@ 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;
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -464,14 +445,12 @@ export const ticketedEventsService = {
|
|||||||
data: { status: 'CANCELLED' },
|
data: { status: 'CANCELLED' },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calendar cache bust (fire-and-forget)
|
// Delete from Gancio if synced + bust calendar cache
|
||||||
|
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;
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -506,10 +485,7 @@ export const ticketedEventsService = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (event.gancioEventId) {
|
if (event.gancioEventId) {
|
||||||
eventBus.publish('ticketed-event.cancelled', {
|
this.deleteFromGancio(event.gancioEventId).catch(() => {});
|
||||||
eventId: event.id,
|
|
||||||
title: event.title,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await prisma.ticketedEvent.delete({ where: { id } });
|
await prisma.ticketedEvent.delete({ where: { id } });
|
||||||
@ -791,4 +767,78 @@ 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,7 +10,6 @@ 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';
|
||||||
@ -116,7 +115,7 @@ router.put(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Self-service password change requires current password verification
|
// Self-service password change requires current password verification
|
||||||
if (isSelf && req.body.password) {
|
if (isSelf && !isAdminUser && 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;
|
||||||
@ -184,14 +183,6 @@ 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,7 +4,6 @@ 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';
|
||||||
@ -123,13 +122,6 @@ 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;
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -190,16 +182,6 @@ 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;
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -216,12 +198,6 @@ 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,7 +14,6 @@ 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';
|
||||||
@ -35,9 +34,6 @@ 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';
|
||||||
@ -127,16 +123,12 @@ 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();
|
||||||
|
|
||||||
@ -280,7 +272,6 @@ 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)
|
||||||
@ -310,9 +301,6 @@ 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)
|
||||||
@ -402,9 +390,6 @@ 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();
|
||||||
|
|
||||||
@ -414,7 +399,6 @@ 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)
|
||||||
@ -448,7 +432,6 @@ 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
|
||||||
@ -560,7 +543,6 @@ 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 { eventBus } from './event-bus.service';
|
import { listmonkEventSyncService } from './listmonk-event-sync.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);
|
||||||
// Publish campaign email sent event
|
// Listmonk event sync
|
||||||
eventBus.publish('campaign.email.sent', {
|
listmonkEventSyncService.onCampaignEmailSent({
|
||||||
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}`);
|
||||||
|
|||||||
@ -1,183 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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();
|
|
||||||
@ -1,268 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,234 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,212 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -1,190 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,177 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 [];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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`);
|
|
||||||
}
|
|
||||||
@ -1,105 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,55 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,118 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,51 +599,6 @@ 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();
|
||||||
|
|||||||
@ -1,129 +0,0 @@
|
|||||||
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 { eventBus } from './event-bus.service';
|
import { listmonkEventSyncService } from './listmonk-event-sync.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);
|
||||||
|
|
||||||
// Publish re-engagement event
|
// Listmonk event sync: tag as re-engaged
|
||||||
eventBus.publish('reengagement.sent', {
|
listmonkEventSyncService.onReengagementSent({
|
||||||
email: volunteer.email,
|
email: volunteer.email,
|
||||||
name: volunteer.name || volunteer.email,
|
name: volunteer.name || volunteer.email,
|
||||||
});
|
}).catch(() => {});
|
||||||
|
|
||||||
sent++;
|
sent++;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@ -58,63 +58,6 @@ 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,7 +4,6 @@ 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
|
||||||
@ -130,13 +129,6 @@ 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 },
|
||||||
@ -144,23 +136,6 @@ 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,7 +2,6 @@ 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)
|
||||||
@ -117,14 +116,6 @@ 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, { apiVersion: '2026-01-28.clover' });
|
_stripe = new Stripe(secretKey);
|
||||||
|
|
||||||
logger.info('Stripe client initialized');
|
logger.info('Stripe client initialized');
|
||||||
return _stripe;
|
return _stripe;
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
import { createHmac } from 'crypto';
|
import { createHmac } from 'crypto';
|
||||||
import { Prisma, UserRole } from '@prisma/client';
|
import { Prisma } 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[]> = {
|
||||||
@ -15,13 +14,9 @@ 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 {
|
||||||
const salt = env.SERVICE_PASSWORD_SALT || env.JWT_ACCESS_SECRET;
|
return createHmac('sha256', env.JWT_ACCESS_SECRET)
|
||||||
return createHmac('sha256', salt)
|
|
||||||
.update(`gitea:${userId}`)
|
.update(`gitea:${userId}`)
|
||||||
.digest('hex');
|
.digest('hex');
|
||||||
}
|
}
|
||||||
@ -99,9 +94,6 @@ 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),
|
||||||
@ -129,9 +121,6 @@ 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> {
|
||||||
@ -151,57 +140,13 @@ 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,8 +16,7 @@ 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 {
|
||||||
const salt = env.SERVICE_PASSWORD_SALT || env.JWT_ACCESS_SECRET;
|
return createHmac('sha256', env.JWT_ACCESS_SECRET)
|
||||||
return createHmac('sha256', salt)
|
|
||||||
.update(`rc:${userId}`)
|
.update(`rc:${userId}`)
|
||||||
.digest('hex');
|
.digest('hex');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,504 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,7 +10,6 @@ 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,
|
||||||
};
|
};
|
||||||
@ -26,7 +25,6 @@ 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
|
||||||
@ -40,7 +38,6 @@ 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