Add video card insert feature + MkDocs video hydration + fixes
- New video card block for GrapesJS landing pages, email templates, MkDocs export, and documentation editor Insert dropdown - Shared HTML generators in admin/src/utils/videoCardHtml.ts - MkDocs video-player.js hydrates .video-card-block elements: thumbnail fix via MEDIA_API_URL, click-to-play inline, Gallery link - Media API CORS: auto-add MkDocs + docs subdomain origins - env_config_hook.py: smart Docker hostname detection, ADMIN_PORT resolution, pass env vars to MkDocs container - Gallery URL uses /gallery?expanded=ID format - VideoPickerModal: fix double /api prefix and Docker hostname thumbs - Seed: default-video-card PageBlock - Remove V1 legacy code (influence/, map/) Bunker Admin
This commit is contained in:
parent
58dc1942ec
commit
99a6abab06
@ -174,6 +174,7 @@ USER_NAME=coder
|
||||
|
||||
# --- Homepage ---
|
||||
HOMEPAGE_PORT=3010
|
||||
HOMEPAGE_EMBED_PORT=8887
|
||||
HOMEPAGE_VAR_BASE_URL=http://localhost
|
||||
|
||||
# --- Mini QR ---
|
||||
|
||||
@ -1,181 +0,0 @@
|
||||
# Pangolin Tunnel Management + Nginx Multi-Domain Support - Implementation Summary
|
||||
|
||||
**Date:** 2026-02-15
|
||||
**Status:** ✅ COMPLETE
|
||||
|
||||
## Changes Implemented
|
||||
|
||||
### 1. Pangolin Resources Configuration (`configs/pangolin/resources.yml`)
|
||||
|
||||
**Added 2 new resources:**
|
||||
- Excalidraw (subdomain: `draw`, container: `excalidraw-changemaker`, port: 80)
|
||||
- MailHog (subdomain: `mail`, container: `mailhog-changemaker`, port: 8025)
|
||||
|
||||
**Fixed Mini QR resource:**
|
||||
- Container name: `miniqr-changemaker` → `mini-qr` ✅
|
||||
- Port: `8089` → `8080` ✅
|
||||
|
||||
**Total resources:** 14 (up from 12)
|
||||
|
||||
### 2. Nginx Template Updates (`nginx/conf.d/services.conf.template`)
|
||||
|
||||
Updated 6 server blocks to support multi-domain routing:
|
||||
|
||||
| Service | Old server_name | New server_name |
|
||||
|---------|----------------|-----------------|
|
||||
| **Gitea** | `git.${DOMAIN}` | `git.cmlite.org git.betteredmonton.org` |
|
||||
| **n8n** | `n8n.${DOMAIN}` | `n8n.cmlite.org n8n.betteredmonton.org` |
|
||||
| **Code Server** | `code.${DOMAIN}` | `code.cmlite.org code.betteredmonton.org` |
|
||||
| **MailHog** | `mail.${DOMAIN}` | `mail.cmlite.org mail.betteredmonton.org` |
|
||||
| **Mini QR** | `qr.${DOMAIN}` | `qr.cmlite.org qr.betteredmonton.org` |
|
||||
| **Excalidraw** | `draw.${DOMAIN}` | `draw.cmlite.org draw.betteredmonton.org` |
|
||||
|
||||
**CSP Headers Updated:**
|
||||
All 6 services now allow iframe embedding from both admin domains:
|
||||
```nginx
|
||||
add_header Content-Security-Policy "frame-ancestors 'self' app.cmlite.org app.betteredmonton.org" always;
|
||||
```
|
||||
|
||||
### 3. Nginx Container Rebuild
|
||||
|
||||
- Rebuilt nginx image to pick up updated template files
|
||||
- Restarted nginx container with new configuration
|
||||
- Verified nginx syntax: ✅ OK
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
### Issue 1: Missing Resources
|
||||
- Excalidraw and MailHog existed in infrastructure but weren't listed in `resources.yml`
|
||||
- Pangolin page reads from this YAML to display resource status
|
||||
|
||||
### Issue 2: X-Frame-Options Error
|
||||
- Nginx template used `${DOMAIN}` variable (only substitutes ONE domain from `.env`)
|
||||
- When accessing via alternate domain, nginx didn't match any server block
|
||||
- Request fell back to default server with `X-Frame-Options: SAMEORIGIN`
|
||||
- This blocked iframe embedding
|
||||
|
||||
### Issue 3: Wrong Container Name
|
||||
- resources.yml referenced `miniqr-changemaker` (doesn't exist)
|
||||
- Correct name from docker-compose.yml: `mini-qr`
|
||||
|
||||
## Solution Applied
|
||||
|
||||
**Pattern:** Hardcoded dual-domain `server_name` directives (same pattern as NocoDB, Listmonk, Grafana, etc.)
|
||||
|
||||
**Why not template variables?**
|
||||
- `${DOMAIN}` substitution only supports ONE domain value
|
||||
- We need BOTH domains for multi-domain production deployment
|
||||
- Hardcoding is explicit and predictable
|
||||
|
||||
## Verification Results
|
||||
|
||||
### ✅ Nginx Configuration
|
||||
```bash
|
||||
$ docker compose exec nginx nginx -t
|
||||
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
|
||||
nginx: configuration file /etc/nginx/nginx.conf test is successful
|
||||
```
|
||||
|
||||
### ✅ Multi-Domain Server Blocks
|
||||
All 6 services verified with both domains in `server_name`:
|
||||
- Mini QR: `qr.cmlite.org qr.betteredmonton.org`
|
||||
- Gitea: `git.cmlite.org git.betteredmonton.org`
|
||||
- n8n: `n8n.cmlite.org n8n.betteredmonton.org`
|
||||
- Code Server: `code.cmlite.org code.betteredmonton.org`
|
||||
- MailHog: `mail.cmlite.org mail.betteredmonton.org`
|
||||
- Excalidraw: `draw.cmlite.org draw.betteredmonton.org`
|
||||
|
||||
### ✅ Resources Count
|
||||
```bash
|
||||
$ grep -c "subdomain:" configs/pangolin/resources.yml
|
||||
14
|
||||
```
|
||||
|
||||
## Testing Instructions
|
||||
|
||||
### 1. Test Pangolin Resources Page
|
||||
```bash
|
||||
# Access Pangolin page
|
||||
# Navigate to http://localhost:3000/app/pangolin
|
||||
# or https://app.betteredmonton.org/app/pangolin
|
||||
|
||||
# Verify:
|
||||
# ✅ Resources table shows 14 services
|
||||
# ✅ "Excalidraw" appears in list
|
||||
# ✅ "MailHog" appears in list
|
||||
# ✅ Mini QR container shows as "mini-qr"
|
||||
```
|
||||
|
||||
### 2. Test Multi-Domain Access (Production)
|
||||
```bash
|
||||
# Test all 6 services respond on both domains
|
||||
for service in qr git n8n code mail draw; do
|
||||
for domain in cmlite.org betteredmonton.org; do
|
||||
echo "Testing ${service}.${domain}..."
|
||||
curl -I https://${service}.${domain} 2>&1 | grep HTTP | head -1
|
||||
done
|
||||
done
|
||||
|
||||
# Expected: All return HTTP/1.1 200 OK (or 302 for auth-protected)
|
||||
```
|
||||
|
||||
### 3. Test Iframe Embedding
|
||||
```bash
|
||||
# Test Mini QR iframe from both domains
|
||||
# 1. Navigate to https://app.betteredmonton.org/app/services/qr
|
||||
# 2. Check browser console for errors
|
||||
# 3. Verify Mini QR loads without "Refused to display" error
|
||||
# 4. Repeat from https://app.cmlite.org/app/services/qr
|
||||
|
||||
# Test other services similarly
|
||||
```
|
||||
|
||||
### 4. Verify CSP Headers
|
||||
```bash
|
||||
# Check CSP headers include both admin domains
|
||||
curl -I https://qr.betteredmonton.org 2>&1 | grep Content-Security-Policy
|
||||
|
||||
# Expected:
|
||||
# Content-Security-Policy: frame-ancestors 'self' app.cmlite.org app.betteredmonton.org
|
||||
```
|
||||
|
||||
## Success Criteria
|
||||
|
||||
✅ Pangolin resources list shows 14 services (up from 12)
|
||||
✅ Excalidraw and MailHog appear in resources table
|
||||
✅ Mini QR container name corrected to `mini-qr`
|
||||
✅ Mini QR iframe loads without X-Frame-Options errors
|
||||
✅ All 6 iframe-embedded services work on both domains
|
||||
✅ CSP headers allow embedding from both app domains
|
||||
✅ Nginx config regenerates successfully without syntax errors
|
||||
|
||||
## Files Modified
|
||||
|
||||
1. **configs/pangolin/resources.yml** - Added 2 resources, fixed 1 container name/port
|
||||
2. **nginx/conf.d/services.conf.template** - Updated 6 server blocks for dual-domain support
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Pangolin Setup** (Manual)
|
||||
- Login to Pangolin dashboard
|
||||
- Create resources for new services (Excalidraw, MailHog)
|
||||
- Set authentication to "Not Protected" for public access
|
||||
- Verify tunnel connectivity
|
||||
|
||||
2. **Production Deployment**
|
||||
- Rebuild nginx image: `docker compose build nginx`
|
||||
- Restart nginx: `docker compose up -d nginx`
|
||||
- Test all services on both domains
|
||||
- Verify iframe embedding works
|
||||
|
||||
3. **Documentation Updates**
|
||||
- Update CLAUDE.md with new resource count
|
||||
- Update MEMORY.md with nginx rebuild requirement for template changes
|
||||
- Document dual-domain pattern in troubleshooting guide
|
||||
|
||||
## Notes
|
||||
|
||||
- **Important:** Template changes require nginx image rebuild (`docker compose build nginx`)
|
||||
- **Pattern:** Hardcoded domains prevent future confusion vs. variable substitution
|
||||
- **Scope:** Frontend already uses `buildServiceUrl()` correctly (no code changes needed)
|
||||
- **Manual Setup:** Pangolin resources.yml is documentation/reference only (manual setup per MEMORY.md)
|
||||
@ -1,200 +0,0 @@
|
||||
# Production 403 Errors - Root Cause & Fix
|
||||
|
||||
## Diagnosis Summary
|
||||
|
||||
**Issue:** All API endpoints returning 302 redirects to Pangolin authentication page
|
||||
**Root Cause:** Pangolin tunnel resources configured with authentication enabled (should be "Not Protected")
|
||||
**Status:** CORS configuration ✅ FIXED | Pangolin resources ❌ NEEDS MANUAL FIX
|
||||
|
||||
---
|
||||
|
||||
## What Was Fixed
|
||||
|
||||
### ✅ CORS Configuration (COMPLETED)
|
||||
|
||||
**File:** `/home/bunker-admin/changemaker.lite/.env`
|
||||
|
||||
**Changes applied:**
|
||||
```bash
|
||||
# Changed from development to production
|
||||
NODE_ENV=production
|
||||
|
||||
# Added production domain to CORS whitelist
|
||||
CORS_ORIGINS=http://app.betteredmonton.org,https://app.betteredmonton.org,http://localhost:3000,http://localhost
|
||||
```
|
||||
|
||||
**API container restarted:** ✅ Done
|
||||
|
||||
---
|
||||
|
||||
## What Still Needs Manual Fix
|
||||
|
||||
### ❌ Pangolin Resource Authentication (REQUIRES MANUAL ACTION)
|
||||
|
||||
**Problem:** Resources are configured with authentication, causing 302 redirects to auth page.
|
||||
|
||||
**Evidence:**
|
||||
```bash
|
||||
$ curl -I https://api.betteredmonton.org/api/health
|
||||
HTTP/2 302
|
||||
location: https://pangolin.bnkserve.org/auth/resource/68488f80-b055-41ea-bc1b-0ab905fb8a53?redirect=...
|
||||
```
|
||||
|
||||
**Fix Required:** Change authentication setting for ALL Pangolin resources to "Not Protected"
|
||||
|
||||
---
|
||||
|
||||
## Step-by-Step Fix Instructions
|
||||
|
||||
### 1. Log in to Pangolin Dashboard
|
||||
|
||||
URL: https://api.bnkserve.org (remove `/v1` from API URL)
|
||||
|
||||
### 2. Navigate to Resources
|
||||
|
||||
Dashboard → **Resources** → **Public**
|
||||
|
||||
### 3. Edit Each Resource
|
||||
|
||||
For EACH of these critical resources:
|
||||
- ✅ **app.betteredmonton.org** (Admin GUI + Public Pages)
|
||||
- ✅ **api.betteredmonton.org** (Main API)
|
||||
- ✅ **media.betteredmonton.org** (Media API)
|
||||
- db.betteredmonton.org (NocoDB)
|
||||
- docs.betteredmonton.org (MkDocs)
|
||||
- code.betteredmonton.org (Code Server)
|
||||
- git.betteredmonton.org (Gitea)
|
||||
- n8n.betteredmonton.org (n8n)
|
||||
- grafana.betteredmonton.org (Grafana)
|
||||
- listmonk.betteredmonton.org (Listmonk)
|
||||
- qr.betteredmonton.org (Mini QR)
|
||||
- home.betteredmonton.org (Homepage)
|
||||
|
||||
**Most critical (fix these first):**
|
||||
1. **api.betteredmonton.org** - Main API (all endpoints fail without this)
|
||||
2. **app.betteredmonton.org** - Admin GUI (login page won't work)
|
||||
3. **media.betteredmonton.org** - Media API (video library features)
|
||||
|
||||
### 4. Change Authentication Setting
|
||||
|
||||
For each resource:
|
||||
1. Click **Edit** (pencil icon)
|
||||
2. Find **Authentication** or **Access Policy** section
|
||||
3. Change from **"Protected"** or **"Authenticated"** to:
|
||||
- **"Not Protected"** OR
|
||||
- **"Public Access"** OR
|
||||
- **"No Authentication"**
|
||||
(exact wording depends on Pangolin UI version)
|
||||
4. Click **Save**
|
||||
|
||||
### 5. Verify Fix
|
||||
|
||||
After changing authentication settings, test each endpoint:
|
||||
|
||||
**Test API:**
|
||||
```bash
|
||||
curl https://api.betteredmonton.org/api/health
|
||||
# Expected: {"status":"healthy","checks":{"database":"ok","redis":"ok"}}
|
||||
# NOT: 302 redirect
|
||||
```
|
||||
|
||||
**Test Public Campaigns:**
|
||||
```bash
|
||||
curl https://api.betteredmonton.org/api/campaigns/public
|
||||
# Expected: JSON array of campaigns
|
||||
# NOT: 302 redirect
|
||||
```
|
||||
|
||||
**Test Admin GUI:**
|
||||
Visit https://app.betteredmonton.org in browser
|
||||
- Should see login page
|
||||
- NO redirect to Pangolin auth page
|
||||
|
||||
---
|
||||
|
||||
## Why This Happened
|
||||
|
||||
1. **Pangolin resources default to "Protected"** - requires manual change to "Not Protected"
|
||||
2. **Manual setup process** - automated setup was removed, so resources must be configured manually
|
||||
3. **No API enforcement** - Pangolin API doesn't enforce "Not Protected" when creating resources programmatically
|
||||
|
||||
---
|
||||
|
||||
## Resource Configuration Reference
|
||||
|
||||
**Correct settings for ALL resources:**
|
||||
- **Protocol:** HTTPS (SSL enabled)
|
||||
- **Target:** nginx:80 (all services route through nginx)
|
||||
- **Authentication:** **Not Protected** ← THIS IS CRITICAL
|
||||
- **SSL/TLS:** Enabled
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Still seeing 302 redirects after changing settings?
|
||||
|
||||
1. **Clear browser cache** - old redirects may be cached
|
||||
2. **Try incognito/private window**
|
||||
3. **Wait 30-60 seconds** - Pangolin may need time to update routing
|
||||
4. **Check resource status** - ensure resource shows as "Active" in Pangolin dashboard
|
||||
5. **Verify target** - should point to `nginx:80` (not individual service ports)
|
||||
|
||||
### API works locally but not via tunnel?
|
||||
|
||||
Confirm:
|
||||
- [ ] Newt container is running: `docker compose ps newt`
|
||||
- [ ] Newt logs show connection: `docker compose logs newt --tail 50`
|
||||
- [ ] PANGOLIN_SITE_ID, PANGOLIN_NEWT_ID, PANGOLIN_NEWT_SECRET are set in .env
|
||||
- [ ] Nginx is running: `docker compose ps nginx`
|
||||
|
||||
### Health endpoint works but other endpoints fail?
|
||||
|
||||
Check in this order:
|
||||
1. Test public endpoints (no auth): `/api/campaigns/public`, `/api/shifts/public`
|
||||
2. Test protected endpoints with valid JWT: `/api/campaigns`, `/api/users`
|
||||
3. Check auth store in browser DevTools: localStorage should have `auth-storage` with tokens
|
||||
4. Verify JWT secrets haven't changed (would invalidate existing tokens)
|
||||
|
||||
---
|
||||
|
||||
## Post-Fix Verification Checklist
|
||||
|
||||
After changing Pangolin resource authentication to "Not Protected":
|
||||
|
||||
- [ ] Health endpoint returns JSON (not 302): `curl https://api.betteredmonton.org/api/health`
|
||||
- [ ] Public campaigns endpoint works: `curl https://api.betteredmonton.org/api/campaigns/public`
|
||||
- [ ] Admin GUI loads: visit https://app.betteredmonton.org
|
||||
- [ ] Login works: can authenticate with admin credentials
|
||||
- [ ] Campaign management page loads data (no console errors)
|
||||
- [ ] Representative lookup functions
|
||||
- [ ] Public campaign page accessible: https://app.betteredmonton.org/campaigns
|
||||
- [ ] Map page loads: https://app.betteredmonton.org/map
|
||||
- [ ] Shifts page works: https://app.betteredmonton.org/shifts
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
**What was done:**
|
||||
1. ✅ Updated `.env` with production CORS origins
|
||||
2. ✅ Set NODE_ENV to production
|
||||
3. ✅ Restarted API container
|
||||
4. ✅ Verified API works locally
|
||||
|
||||
**What you need to do:**
|
||||
1. ❌ Log in to Pangolin dashboard at https://api.bnkserve.org
|
||||
2. ❌ Edit each resource and set Authentication to "Not Protected"
|
||||
3. ❌ Verify endpoints no longer return 302 redirects
|
||||
4. ❌ Test application is fully functional
|
||||
|
||||
**Time estimate:** 5-10 minutes to update all 12 resources
|
||||
|
||||
---
|
||||
|
||||
## Contact
|
||||
|
||||
If you encounter issues after following these steps:
|
||||
- Check Pangolin documentation: https://pangolin.bnkserve.org/docs (if available)
|
||||
- Review Newt container logs: `docker compose logs newt`
|
||||
- Verify nginx routing: `docker compose logs nginx | grep betteredmonton.org`
|
||||
389
V2_PLAN.md
389
V2_PLAN.md
@ -1,389 +0,0 @@
|
||||
# V2 Roadmap — Changemaker Lite
|
||||
|
||||
This document is the full roadmap for the v2 rebuild of Changemaker Lite, a self-hosted political campaign platform. V1 consisted of two separate Express apps (Influence and Map) using NocoDB as a data layer. V2 consolidates everything into a single unified TypeScript API with a React admin interface.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
- **Single unified Express.js API** (TypeScript, port 4000) with Prisma ORM + PostgreSQL 16
|
||||
- **React Admin GUI** (Vite + Ant Design + Zustand, port 3000)
|
||||
- **Nginx reverse proxy** (subdomain routing: *.cmlite.org)
|
||||
- **NocoDB v2** as read-only data browser on port 8091
|
||||
- **JWT auth** (access + refresh tokens), bcrypt passwords
|
||||
- **BullMQ** for Influence advocacy emails, Listmonk for newsletters
|
||||
- **Redis** for caching, rate limiting, BullMQ
|
||||
|
||||
---
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
changemaker.lite/
|
||||
├── api/ # Unified Express.js API (TypeScript)
|
||||
│ ├── prisma/ # Schema, migrations, seed
|
||||
│ └── src/
|
||||
│ ├── config/ # env.ts, database.ts, redis.ts
|
||||
│ ├── middleware/ # error-handler, validate, rate-limit, auth, rbac
|
||||
│ ├── modules/
|
||||
│ │ ├── auth/ # auth.service, auth.routes, auth.schemas
|
||||
│ │ ├── users/ # users.service, users.routes, users.schemas
|
||||
│ │ ├── influence/
|
||||
│ │ │ ├── campaigns/
|
||||
│ │ │ ├── representatives/
|
||||
│ │ │ ├── responses/
|
||||
│ │ │ └── postal-codes/
|
||||
│ │ └── map/
|
||||
│ │ ├── locations/
|
||||
│ │ ├── shifts/
|
||||
│ │ └── cuts/
|
||||
│ ├── types/ # express.d.ts
|
||||
│ └── utils/ # logger.ts, metrics.ts
|
||||
├── admin/ # React Admin (Vite + Ant Design)
|
||||
│ └── src/
|
||||
│ ├── components/
|
||||
│ ├── pages/
|
||||
│ ├── stores/
|
||||
│ └── services/
|
||||
├── nginx/ # Reverse proxy config
|
||||
├── public-web/ # Public landing pages
|
||||
└── docker-compose.yml # V2 orchestration
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Schema Summary
|
||||
|
||||
27+ models organized into the following groups:
|
||||
|
||||
- **Auth & Users** — User, RefreshToken
|
||||
- **Influence** — Campaign, CampaignEmail, Representative, RepresentativeResponse, ResponseUpvote, CustomRecipient, PostalCodeCache, EmailLog, EmailVerification, Call
|
||||
- **Map** — Location, Shift, ShiftSignup, Cut, MapSettings
|
||||
- **Canvassing** — CanvassSession, CanvassVisit, TrackingSession, TrackPoint
|
||||
- **Landing Pages** — LandingPage, PageBlock
|
||||
- **Settings** — SiteSettings
|
||||
|
||||
---
|
||||
|
||||
## Auth Flow
|
||||
|
||||
- JWT-based with access tokens (15min) and refresh tokens (7 days, stored in DB)
|
||||
- **Login:** verify bcrypt hash, generate token pair, return tokens + user
|
||||
- **Refresh:** validate refresh token, rotate (invalidate old, issue new), return new pair
|
||||
- **Roles:** SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN, USER, TEMP
|
||||
|
||||
---
|
||||
|
||||
## Email Architecture
|
||||
|
||||
- **BullMQ job queue** for Influence advocacy emails (async send via SMTP)
|
||||
- **Listmonk** for newsletter/marketing emails
|
||||
- **MailHog** for dev email capture (EMAIL_TEST_MODE=true)
|
||||
|
||||
---
|
||||
|
||||
## Nginx Routing
|
||||
|
||||
| Subdomain | Target |
|
||||
|-----------------------|--------------------------------|
|
||||
| app.cmlite.org | Admin React app (port 3000) |
|
||||
| api.cmlite.org | Express API (port 4000) |
|
||||
| db.cmlite.org | NocoDB read-only (port 8091) |
|
||||
| docs.cmlite.org | MkDocs (port 4003) |
|
||||
| code.cmlite.org | Code Server (port 8888) |
|
||||
| n8n.cmlite.org | n8n Workflows (port 5678) |
|
||||
| git.cmlite.org | Gitea (port 3030) |
|
||||
| home.cmlite.org | Homepage (port 3010) |
|
||||
| grafana.cmlite.org | Grafana (port 3001) |
|
||||
| listmonk.cmlite.org | Listmonk (port 9001) |
|
||||
| cmlite.org | Public static site (MkDocs) |
|
||||
|
||||
---
|
||||
|
||||
## Phase Checklist
|
||||
|
||||
### Phase 1: Foundation [x] COMPLETE
|
||||
|
||||
- [x] Initialize api/ with TypeScript, Express, Prisma
|
||||
- [x] Create prisma/schema.prisma with all models
|
||||
- [x] Set up config (env.ts, database.ts, redis.ts)
|
||||
- [x] Create middleware (error-handler.ts, validate.ts, rate-limit.ts)
|
||||
- [x] Set up utils (logger.ts, metrics.ts)
|
||||
- [x] Create server.ts with health check and metrics
|
||||
- [x] Initialize admin/ with Vite + React + Ant Design
|
||||
- [x] Create docker-compose.yml for v2
|
||||
- [x] Create .env.example
|
||||
- [x] Backup v1 to docker-compose.v1.yml
|
||||
|
||||
> Note: DB migrations are pending until `docker compose up` runs PostgreSQL
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Auth + User Management [x] COMPLETE
|
||||
|
||||
- [x] Express Request type augmentation (express.d.ts)
|
||||
- [x] Auth schemas (Zod validation)
|
||||
- [x] Auth service (login, register, refresh, logout)
|
||||
- [x] Auth middleware (JWT verification)
|
||||
- [x] RBAC middleware (role-based access)
|
||||
- [x] Auth routes (login, register, refresh, logout, me)
|
||||
- [x] User schemas (Zod validation)
|
||||
- [x] User service (CRUD + pagination)
|
||||
- [x] User routes (list, get, create, update, delete)
|
||||
- [x] Wire routes into server.ts
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Admin GUI Foundation [x] COMPLETE
|
||||
|
||||
- [x] Zustand auth store with token management
|
||||
- [x] Login page
|
||||
- [x] Protected route wrapper
|
||||
- [x] App layout with sidebar navigation
|
||||
- [x] User management page (CRUD table)
|
||||
- [x] API client with interceptors (auto-refresh)
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Influence — Campaigns [x] COMPLETE
|
||||
|
||||
- [x] Campaign schemas (Zod validation)
|
||||
- [x] Campaign service (CRUD, slug generation, highlight toggling)
|
||||
- [x] Campaign routes with admin protection
|
||||
- [x] Admin campaign management page (table, filters, create/edit/delete)
|
||||
- [ ] Public campaign view page (deferred to later phase)
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: Influence — Representatives + Postal Codes [x] COMPLETE
|
||||
|
||||
- [x] Postal code schemas (normalize, validate all Canadian postal codes)
|
||||
- [x] Postal code cache service (upsert, find, paginate, delete)
|
||||
- [x] Represent API client (typed HTTP client, in-memory rate limiter 55/min)
|
||||
- [x] Representative schemas (list/filter validation)
|
||||
- [x] Representative service (cache-first lookup, fire-and-forget cache write, admin CRUD, cache stats)
|
||||
- [x] Representative routes (public: lookup + health check; admin: list, stats, detail, delete)
|
||||
- [x] Admin representatives page (lookup, stats cards, table with filters, detail modal)
|
||||
- [ ] Public postal code to representative lookup UI (deferred to later phase)
|
||||
|
||||
---
|
||||
|
||||
### Phase 6: Influence — Email Sending [x] COMPLETE
|
||||
|
||||
- [x] BullMQ email queue setup (email-queue.service.ts)
|
||||
- [x] Email worker (SMTP send via nodemailer, email.service.ts)
|
||||
- [x] Campaign email service (compose, queue, track)
|
||||
- [x] Campaign email routes (public: send-email, track-mailto; admin: list, stats)
|
||||
- [x] Email queue admin routes (stats, pause, resume, clean)
|
||||
- [x] Admin email queue page (stats cards, pause/resume, clean)
|
||||
- [x] Admin campaign emails drawer (stats + email list from CampaignsPage)
|
||||
- [x] Email rate limiting (30 req/hour per IP)
|
||||
- [ ] Public email sending UI (SMTP + mailto fallback) — deferred to later phase
|
||||
|
||||
---
|
||||
|
||||
### Phase 7: Influence — Response Wall + Public Campaign View [x]
|
||||
|
||||
- [x] Response service (submit, moderate, verify)
|
||||
- [x] Response routes (3 routers: campaign-public, response-public, admin)
|
||||
- [x] Email verification for responses (HTML templates, verify/report endpoints)
|
||||
- [x] Admin moderation interface (ResponsesPage with filters, approve/reject/delete)
|
||||
- [x] Public response wall display (ResponseWallPage with sort, filter, submit modal)
|
||||
- [x] Upvoting system (IP + user dedup, optimistic UI)
|
||||
- [x] Public campaign page (CampaignPage with postal code lookup, email sending)
|
||||
- [x] PublicLayout with light theme for public pages
|
||||
- [x] Campaign public details endpoint (findBySlugPublic)
|
||||
|
||||
---
|
||||
|
||||
### Phase 8: Map — Locations [x]
|
||||
|
||||
- [x] Multi-provider geocoding service (Nominatim, ArcGIS, Photon, Mapbox)
|
||||
- [x] Location service (CRUD, geocoding, stats, bulk operations)
|
||||
- [x] Location routes (admin + public)
|
||||
- [x] Map settings service + routes (singleton config)
|
||||
- [x] Admin LocationsPage (table, stats, create/edit, geocode button)
|
||||
- [x] Admin MapSettingsPage (center/zoom, walk sheet config)
|
||||
- [x] Public Leaflet.js map (circle markers, color-coded support levels, multi-unit grouping)
|
||||
- [x] CSV import/export with flexible column mapping
|
||||
|
||||
---
|
||||
|
||||
### Phase 9: Map — Shifts [x] COMPLETE
|
||||
|
||||
- [x] Shift service (CRUD, signup management)
|
||||
- [x] Shift routes (admin + public)
|
||||
- [x] Admin shift management (ShiftsPage with signups drawer)
|
||||
- [x] Public shift calendar + signup
|
||||
- [x] Temp user creation on public signup
|
||||
- [x] Confirmation emails for signups
|
||||
|
||||
---
|
||||
|
||||
### Phase 10: Walk Sheets & QR Codes [x] COMPLETE
|
||||
|
||||
- [x] QR code generation endpoint (GET /api/qr)
|
||||
- [x] Walk sheet page (printable form with QR codes)
|
||||
- [x] Cut export page (printable location report)
|
||||
- [x] Sidebar navigation + route wiring
|
||||
|
||||
---
|
||||
|
||||
### Phase 11: Listmonk Integration [x] COMPLETE
|
||||
|
||||
- [x] Listmonk API client service (typed HTTP, basic auth)
|
||||
- [x] Sync service (campaign participants, locations, users → subscriber lists)
|
||||
- [x] Admin routes (status, stats, sync triggers, test connection, reinitialize)
|
||||
- [x] Admin ListmonkPage (status, sync buttons, list stats)
|
||||
- [x] Sidebar navigation + route wiring
|
||||
- [x] Proton Mail SMTP configuration (listmonk-init auto-configures via SQL)
|
||||
|
||||
---
|
||||
|
||||
### Phase 12: Landing Page Builder [x] COMPLETE
|
||||
|
||||
- [x] Landing page service (CRUD, slug generation, MkDocs export)
|
||||
- [x] Page block service (seed blocks, CRUD, library API)
|
||||
- [x] GrapesJS editor integration (custom blocks, Ctrl+S save, error boundary)
|
||||
- [x] Admin page builder UI (LandingPagesPage, PageEditorPage full-screen)
|
||||
- [x] Public page rendering (/p/:slug)
|
||||
- [x] MkDocs export (Jinja2 Material override template, themed + standalone modes)
|
||||
|
||||
---
|
||||
|
||||
### MkDocs + Code Server + Docs Editor [x] COMPLETE
|
||||
|
||||
- [x] Docker port fix (MkDocs → 4003, avoiding API conflict)
|
||||
- [x] API mkdocs volume mount for override file sync
|
||||
- [x] Nginx code.cmlite.org + X-Frame-Options per-server-block
|
||||
- [x] Docs API routes (status, config health check for MkDocs/Code Server)
|
||||
- [x] DocsEditorPage (split-view with Code Server + MkDocs iframes, draggable divider)
|
||||
- [x] DocsPage (management — status cards, MkDocs export table)
|
||||
|
||||
---
|
||||
|
||||
### Phase 13: Settings + Branding [x] COMPLETE
|
||||
|
||||
- [x] SiteSettings Prisma model (singleton, organization/theme/feature toggles)
|
||||
- [x] Settings API module (public GET, SUPER_ADMIN PUT)
|
||||
- [x] Zustand settings store (fetch on app startup, public endpoint)
|
||||
- [x] Admin SettingsPage with tabs (Organization, Theme, Email, Features)
|
||||
- [x] Dynamic admin theme (colorPrimary, colorBgBase from settings)
|
||||
- [x] Dynamic public theme (colors, gradient, footer from settings)
|
||||
- [x] Dynamic branding (organization name, short name, login subtitle)
|
||||
- [x] Feature toggle navigation (hide sidebar sections when disabled)
|
||||
|
||||
---
|
||||
|
||||
### Code Editor (Standalone) [x] COMPLETE
|
||||
|
||||
- [x] Docker volume mount for entire project directory
|
||||
- [x] Admin CodeEditorPage with full-bleed Code Server iframe
|
||||
- [x] Health status check (reuses /api/docs/status endpoint)
|
||||
- [x] Sidebar navigation under Web submenu
|
||||
- [x] SUPER_ADMIN access restriction
|
||||
|
||||
---
|
||||
|
||||
### Volunteer Canvassing System [x] COMPLETE
|
||||
|
||||
- [x] CanvassSession + CanvassVisit Prisma models (session lifecycle, visit outcomes)
|
||||
- [x] TrackingSession + TrackPoint Prisma models (GPS trail recording)
|
||||
- [x] Canvass API — volunteer routes (start/end session, record visits, walking route)
|
||||
- [x] Canvass API — admin routes (dashboard stats, activity feed, cut progress, leaderboard)
|
||||
- [x] Walking route algorithm (nearest-neighbor with haversine distance)
|
||||
- [x] Canvass visit rate limiter (30/min per IP)
|
||||
- [x] Abandoned session cleanup (startup + hourly interval, ACTIVE > 12h → ABANDONED)
|
||||
- [x] GPS tracking routes (volunteer + admin)
|
||||
- [x] Old tracking data cleanup (startup + daily, 30-day retention)
|
||||
- [x] Stale tracking session cleanup (no data for 2h, hourly check)
|
||||
- [x] VolunteerLayout (top-nav, dark theme, mobile hamburger menu)
|
||||
- [x] Volunteer portal — full-screen canvass map (Leaflet, GPS, markers, route, bottom sheet visit recording)
|
||||
- [x] Volunteer portal — activity page (visit history + outcome breakdown)
|
||||
- [x] Admin CanvassDashboardPage (stats, activity feed, cut progress, leaderboard)
|
||||
- [x] Admin WalkSheetPage enhancements
|
||||
- [x] ShiftsPage cutId dropdown (link shifts to cuts)
|
||||
- [x] Role-aware login redirect (ADMIN_ROLES → /app, USER/TEMP → /volunteer)
|
||||
- [x] Shift.cutId optional relation — shifts without a cut don't appear in volunteer assignments
|
||||
|
||||
---
|
||||
|
||||
### Platform Service Integration [x] COMPLETE
|
||||
|
||||
- [x] Services API module (health check NocoDB, n8n, Gitea + config endpoint)
|
||||
- [x] Admin NocoDBPage (iframe to db.cmlite.org, status badge, open in new tab)
|
||||
- [x] Admin N8nPage (iframe to n8n.cmlite.org, status badge, open in new tab)
|
||||
- [x] Admin GiteaPage (iframe to git.cmlite.org, status badge, open in new tab)
|
||||
- [x] Sidebar "Services" submenu (Database, Workflows, Git)
|
||||
- [x] Nginx CSP frame-ancestors for NocoDB, n8n, Gitea (iframe embedding from admin)
|
||||
- [x] Embed proxy ports (8881-8883) for X-Frame-Options stripping
|
||||
- [x] `buildServiceUrl()` helper for dynamic iframe URLs
|
||||
|
||||
---
|
||||
|
||||
### Phase 14: Monitoring + DevOps [x] COMPLETE
|
||||
|
||||
**Pangolin Tunnel (replaced Cloudflare Tunnel):**
|
||||
- [x] Pangolin Integration API client (`api/src/services/pangolin.client.ts`)
|
||||
- [x] Admin pangolin routes — status, config, sites, resources, setup, sync, delete
|
||||
- [x] Admin PangolinPage — setup wizard + resource status dashboard
|
||||
- [x] Newt container in docker-compose.yml
|
||||
- [x] Env vars: PANGOLIN_API_URL, API_KEY, ORG_ID, SITE_ID, ENDPOINT, NEWT_ID, NEWT_SECRET
|
||||
- [x] Retired Cloudflare scripts → `scripts/legacy/`
|
||||
- [x] Sidebar "Tunnel" nav item under Services
|
||||
|
||||
**Prometheus Metrics:**
|
||||
- [x] 12 domain-specific `cm_*` metrics in `api/src/utils/metrics.ts`
|
||||
- [x] Email: cm_emails_sent_total, cm_emails_failed_total, cm_email_queue_size, cm_email_send_duration_seconds
|
||||
- [x] Auth: cm_login_attempts_total, cm_active_sessions
|
||||
- [x] Campaigns: cm_campaign_emails_total, cm_response_submissions_total
|
||||
- [x] Canvass: cm_canvass_visits_total, cm_active_canvass_sessions, cm_shift_signups_total
|
||||
- [x] Services: cm_external_service_up
|
||||
- [x] Instrumented: email-queue, auth, campaign-emails, responses, canvass, shifts, services
|
||||
|
||||
**Monitoring Configs:**
|
||||
- [x] Prometheus: V2 API scrape job (`changemaker-v2-api:4000`), removed V1 influence-app
|
||||
- [x] Alerts: rewritten for V2 `cm_*` / `http_*` metric names
|
||||
- [x] Alertmanager: Gotify webhook receiver (commented, ready to enable)
|
||||
- [x] Grafana dashboards: system-health (updated), application-overview (new), api-performance (new)
|
||||
|
||||
**Docker Healthchecks:**
|
||||
- [x] API (wget /api/health, 15s)
|
||||
- [x] Admin (wget /, 30s)
|
||||
- [x] Nginx (wget /, 30s)
|
||||
- [x] NocoDB (wget /api/v1/health, 30s)
|
||||
- [x] n8n (wget /healthz, 30s)
|
||||
- [x] Gitea (curl /, 30s)
|
||||
- [x] Listmonk (wget /, 30s)
|
||||
|
||||
**Backup:**
|
||||
- [x] `scripts/backup.sh` — V2 PostgreSQL dump + Listmonk dump + uploads archive
|
||||
- [x] Manifest with timestamps, sizes, sha256 checksums
|
||||
- [x] Configurable retention (default 30 days)
|
||||
- [x] Optional S3 upload (--s3 flag)
|
||||
|
||||
---
|
||||
|
||||
### Phase 15: Testing + Polish [IN PROGRESS]
|
||||
|
||||
**Media Admin Features (Feb 2026) [COMPLETE]:**
|
||||
- [x] Quick Action Buttons — Edit, preview, analytics, schedule, duplicate, preview links (24h JWT), reset analytics
|
||||
- [x] Scheduled Publishing — BullMQ job queue, timezone support (11 zones), calendar view, publish/unpublish automation
|
||||
- [x] Video Analytics — Views, watch time, completion rate, traffic sources, registered viewers tracking
|
||||
- [x] Privacy & Compliance — IP hashing (SHA-256), user agent truncation, 90-day retention, GDPR-compliant
|
||||
- [x] UI/UX Polish — Keyboard shortcuts (E/P/A/S), hover overlays, skeleton loading, error handling, mobile responsive
|
||||
- [x] Documentation — MEDIA_ADMIN_FEATURES.md, VIDEO_ANALYTICS_GUIDE.md, api/src/modules/media/README.md
|
||||
|
||||
**Remaining Testing + Polish:**
|
||||
- [ ] API integration tests (Jest/Vitest)
|
||||
- [ ] Admin E2E tests
|
||||
- [ ] Performance optimization
|
||||
- [ ] Security audit (auth-security-reviewer for media features)
|
||||
- [ ] UI design review (ui-design-critic for media components)
|
||||
|
||||
### PHASE 1: Extras
|
||||
- [ ] Add apache answers
|
||||
- [ ] Add geo-tracking and blocking
|
||||
- [ ] Add in the video platform
|
||||
- [ ] Add in excalidraw
|
||||
- [ ] Add in chats - integrate into canvass application
|
||||
7
admin/package-lock.json
generated
7
admin/package-lock.json
generated
@ -30,6 +30,7 @@
|
||||
"grapesjs-typed": "^2.0.1",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"minisearch": "^7.2.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
@ -2535,6 +2536,12 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/minisearch": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.2.0.tgz",
|
||||
"integrity": "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/monaco-editor": {
|
||||
"version": "0.55.1",
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
|
||||
|
||||
@ -31,6 +31,7 @@
|
||||
"grapesjs-typed": "^2.0.1",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"minisearch": "^7.2.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-leaflet": "^5.0.0",
|
||||
|
||||
@ -13,6 +13,7 @@ import exportPlugin from 'grapesjs-plugin-export';
|
||||
import styleGradientPlugin from 'grapesjs-style-gradient';
|
||||
import touchPlugin from 'grapesjs-touch';
|
||||
import type { PageBlock } from '@/types/api';
|
||||
import { generateVideoCardHtml } from '@/utils/videoCardHtml';
|
||||
|
||||
interface GrapesJSEditorProps {
|
||||
initialData?: Record<string, unknown>;
|
||||
@ -247,6 +248,44 @@ function generateBlockHtml(type: string, defaults: Record<string, unknown>): str
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
case 'video-card': {
|
||||
const videoId = defaults.videoId;
|
||||
const title = (defaults.title as string) || 'Video Title';
|
||||
const durationSeconds = (defaults.durationSeconds as number) || 0;
|
||||
const quality = (defaults.quality as string) || '';
|
||||
const viewCount = (defaults.viewCount as number) || 0;
|
||||
const mediaApiUrl = 'http://localhost:4100';
|
||||
|
||||
if (!videoId || videoId === 'PLACEHOLDER') {
|
||||
return `
|
||||
<section style="padding: 40px 20px;">
|
||||
<div class="video-card-block" style="max-width: 480px; margin: 0 auto; border-radius: 12px; overflow: hidden; background: #1b2838; box-shadow: 0 4px 12px rgba(0,0,0,0.3);">
|
||||
<div style="padding-bottom: 56.25%; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); position: relative;">
|
||||
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); text-align: center; color: #fff;">
|
||||
<svg style="width: 48px; height: 48px; margin-bottom: 8px; opacity: 0.9;" fill="currentColor" viewBox="0 0 20 20"><path d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z"/></svg>
|
||||
<p style="margin: 0; font-size: 14px; font-weight: 600;">Video Card</p>
|
||||
<p style="margin: 4px 0 0; font-size: 12px; opacity: 0.7;">Select a video to display</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding: 12px 16px;">
|
||||
<div style="color: #fff; font-size: 15px; font-weight: 600;">Video Title</div>
|
||||
<div style="color: #8899aa; font-size: 13px; margin-top: 6px;">Card will render on published page</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
const cardHtml = generateVideoCardHtml({
|
||||
id: videoId as number,
|
||||
title,
|
||||
durationSeconds,
|
||||
quality,
|
||||
viewCount,
|
||||
thumbnailUrl: `${mediaApiUrl}/api/videos/${videoId}/thumbnail`,
|
||||
});
|
||||
|
||||
return `<section style="padding: 40px 20px;">${cardHtml}</section>`;
|
||||
}
|
||||
default:
|
||||
return `<section style="padding: 40px; text-align: center;"><p>Custom block: ${type}</p></section>`;
|
||||
}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Badge, Drawer } from 'antd';
|
||||
import {
|
||||
MenuOutlined,
|
||||
ArrowRightOutlined,
|
||||
NodeIndexOutlined,
|
||||
AimOutlined,
|
||||
@ -14,11 +13,13 @@ import {
|
||||
UpOutlined,
|
||||
DownOutlined,
|
||||
GlobalOutlined,
|
||||
BookOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { PublicCut } from '@/types/api';
|
||||
import CutOverlayControls from '@/components/map/CutOverlayControls';
|
||||
import CanvassLegend from './CanvassLegend';
|
||||
import AddressSearchOverlay from './AddressSearchOverlay';
|
||||
import DocsSearchOverlay from './DocsSearchOverlay';
|
||||
import { TILE_LAYERS } from '@/components/map/tileLayers';
|
||||
|
||||
interface BottomControlPanelProps {
|
||||
@ -75,6 +76,7 @@ export default function BottomControlPanel({
|
||||
const [showCuts, setShowCuts] = useState(false);
|
||||
const [showLegend, setShowLegend] = useState(false);
|
||||
const [showTiles, setShowTiles] = useState(false);
|
||||
const [showDocs, setShowDocs] = useState(false);
|
||||
|
||||
// Compact icon button with scale feedback
|
||||
const IconButton = ({
|
||||
@ -218,20 +220,20 @@ export default function BottomControlPanel({
|
||||
<div style={{ display: 'flex', gap: 4, flex: sessionActive ? 0 : 1, justifyContent: sessionActive ? 'flex-start' : 'center' }}>
|
||||
<IconButton
|
||||
icon={<GlobalOutlined />}
|
||||
onClick={() => setShowTiles(!showTiles)}
|
||||
onClick={() => { setShowTiles(!showTiles); setShowSearch(false); setShowCuts(false); setShowLegend(false); setShowDocs(false); }}
|
||||
label="Map tiles"
|
||||
type={showTiles ? 'primary' : 'default'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<SearchOutlined />}
|
||||
onClick={() => setShowSearch(!showSearch)}
|
||||
onClick={() => { setShowSearch(!showSearch); setShowTiles(false); setShowCuts(false); setShowLegend(false); setShowDocs(false); }}
|
||||
label="Search"
|
||||
type={showSearch ? 'primary' : 'default'}
|
||||
/>
|
||||
{cuts.length > 0 && (
|
||||
<IconButton
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => setShowCuts(!showCuts)}
|
||||
onClick={() => { setShowCuts(!showCuts); setShowTiles(false); setShowSearch(false); setShowLegend(false); setShowDocs(false); }}
|
||||
label="Cuts"
|
||||
type={showCuts ? 'primary' : 'default'}
|
||||
badge={visibleCutIds.size > 0 ? visibleCutIds.size : undefined}
|
||||
@ -239,10 +241,16 @@ export default function BottomControlPanel({
|
||||
)}
|
||||
<IconButton
|
||||
icon={<InfoCircleOutlined />}
|
||||
onClick={() => setShowLegend(!showLegend)}
|
||||
onClick={() => { setShowLegend(!showLegend); setShowTiles(false); setShowSearch(false); setShowCuts(false); setShowDocs(false); }}
|
||||
label="Legend"
|
||||
type={showLegend ? 'primary' : 'default'}
|
||||
/>
|
||||
<IconButton
|
||||
icon={<BookOutlined />}
|
||||
onClick={() => { setShowDocs(!showDocs); setShowTiles(false); setShowSearch(false); setShowCuts(false); setShowLegend(false); }}
|
||||
label="Docs"
|
||||
type={showDocs ? 'primary' : 'default'}
|
||||
/>
|
||||
{onAddAtCenter && (
|
||||
<IconButton
|
||||
icon={<PlusOutlined />}
|
||||
@ -413,6 +421,24 @@ export default function BottomControlPanel({
|
||||
>
|
||||
<CanvassLegend style={{ position: 'relative', top: 0, right: 0, maxWidth: '100%' }} />
|
||||
</Drawer>
|
||||
|
||||
{/* Docs search drawer */}
|
||||
<Drawer
|
||||
placement="top"
|
||||
open={showDocs}
|
||||
onClose={() => setShowDocs(false)}
|
||||
height="auto"
|
||||
closable={false}
|
||||
maskClosable
|
||||
mask={false}
|
||||
zIndex={1090}
|
||||
styles={{
|
||||
body: { padding: '12px 16px', background: 'rgba(13, 27, 42, 0.98)' },
|
||||
wrapper: { top: 'env(safe-area-inset-top)' },
|
||||
}}
|
||||
>
|
||||
<DocsSearchOverlay />
|
||||
</Drawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
242
admin/src/components/canvass/DocsSearchOverlay.tsx
Normal file
242
admin/src/components/canvass/DocsSearchOverlay.tsx
Normal file
@ -0,0 +1,242 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Input, Spin, Button, Grid, Typography } from 'antd';
|
||||
import type { InputRef } from 'antd';
|
||||
import { ReloadOutlined } from '@ant-design/icons';
|
||||
import { useDebounce } from '@/hooks/useDebounce';
|
||||
import { useDocsSearch } from '@/hooks/useDocsSearch';
|
||||
import type { DocSearchResult } from '@/hooks/useDocsSearch';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
export default function DocsSearchOverlay() {
|
||||
const [query, setQuery] = useState('');
|
||||
const debouncedQuery = useDebounce(query, 300);
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
|
||||
const { loading, error, indexReady, results, loadIndex, search, retry } =
|
||||
useDocsSearch();
|
||||
|
||||
// Load index on first mount
|
||||
useEffect(() => {
|
||||
loadIndex().catch(() => {/* error state handled by hook */});
|
||||
}, [loadIndex]);
|
||||
|
||||
// Autofocus after index loads
|
||||
useEffect(() => {
|
||||
if (indexReady) {
|
||||
setTimeout(() => inputRef.current?.focus(), 100);
|
||||
}
|
||||
}, [indexReady]);
|
||||
|
||||
// Search on debounced query change OR when index becomes ready
|
||||
useEffect(() => {
|
||||
search(debouncedQuery);
|
||||
}, [debouncedQuery, search, indexReady]);
|
||||
|
||||
// ---- Loading state ----
|
||||
if (loading && !indexReady) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '16px 0' }}>
|
||||
<Spin size="small" />
|
||||
<Text style={{ color: 'rgba(255,255,255,0.6)', marginLeft: 8 }}>
|
||||
Loading docs index...
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Error state ----
|
||||
if (error) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', padding: '16px 0' }}>
|
||||
<Text style={{ color: '#ff4d4f' }}>{error}</Text>
|
||||
<br />
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={retry}
|
||||
style={{ marginTop: 8 }}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const qrSize = isMobile ? 100 : 120;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder="Search documentation..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
allowClear
|
||||
autoFocus
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.08)',
|
||||
border: '1px solid rgba(255,255,255,0.15)',
|
||||
color: '#fff',
|
||||
borderRadius: 6,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: 8,
|
||||
maxHeight: '55vh',
|
||||
overflowY: 'auto',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
}}
|
||||
>
|
||||
{/* Hint states */}
|
||||
{!query && results.length === 0 && (
|
||||
<Text
|
||||
style={{
|
||||
color: 'rgba(255,255,255,0.4)',
|
||||
display: 'block',
|
||||
padding: '12px 0',
|
||||
textAlign: 'center',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
Type at least 2 characters to search
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{query && debouncedQuery.length >= 2 && results.length === 0 && !loading && (
|
||||
<Text
|
||||
style={{
|
||||
color: 'rgba(255,255,255,0.4)',
|
||||
display: 'block',
|
||||
padding: '12px 0',
|
||||
textAlign: 'center',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
No results found
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Results */}
|
||||
{results.map((r, i) => (
|
||||
<ResultCard key={i} result={r} query={debouncedQuery} qrSize={qrSize} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Result card ----
|
||||
|
||||
function ResultCard({
|
||||
result,
|
||||
query,
|
||||
qrSize,
|
||||
}: {
|
||||
result: DocSearchResult;
|
||||
query: string;
|
||||
qrSize: number;
|
||||
}) {
|
||||
const highlighted = highlightSnippet(result.snippet, query);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 10,
|
||||
padding: '10px 12px',
|
||||
marginBottom: 6,
|
||||
background: 'rgba(255,255,255,0.06)',
|
||||
borderRadius: 8,
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
}}
|
||||
>
|
||||
{/* Text content */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<a
|
||||
href={result.docUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{
|
||||
color: '#69b1ff',
|
||||
fontWeight: 600,
|
||||
fontSize: 14,
|
||||
textDecoration: 'none',
|
||||
display: 'block',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{result.title}
|
||||
</a>
|
||||
|
||||
<div
|
||||
style={{
|
||||
color: 'rgba(255,255,255,0.7)',
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
marginTop: 4,
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: highlighted }}
|
||||
/>
|
||||
|
||||
<Text
|
||||
style={{
|
||||
color: 'rgba(255,255,255,0.3)',
|
||||
fontSize: 11,
|
||||
marginTop: 4,
|
||||
display: 'block',
|
||||
}}
|
||||
>
|
||||
{result.location}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* QR code */}
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
<img
|
||||
src={result.qrUrl}
|
||||
alt="QR"
|
||||
width={qrSize}
|
||||
height={qrSize}
|
||||
loading="lazy"
|
||||
style={{
|
||||
borderRadius: 4,
|
||||
background: '#fff',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Highlight helper ----
|
||||
|
||||
function highlightSnippet(snippet: string, query: string): string {
|
||||
if (!query || query.length < 2) return escapeHtml(snippet);
|
||||
const escaped = escapeHtml(snippet);
|
||||
const escapedQuery = escapeRegex(query);
|
||||
const regex = new RegExp(`(${escapedQuery})`, 'gi');
|
||||
return escaped.replace(
|
||||
regex,
|
||||
'<mark style="background:#e2b714;color:#000;border-radius:2px;padding:0 1px">$1</mark>',
|
||||
);
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
@ -50,7 +50,6 @@ export default function VisitRecordingForm({
|
||||
const [signSize, setSignSize] = useState<string | undefined>(undefined);
|
||||
const [notes, setNotes] = useState('');
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
const isNarrow = !screens.sm;
|
||||
|
||||
const showDetailFields = userRole !== 'TEMP';
|
||||
|
||||
@ -19,7 +19,7 @@ import type { PublicCut } from '@/types/api';
|
||||
interface VolunteerMapDrawerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
drawerBodyRef?: React.RefObject<HTMLDivElement>;
|
||||
drawerBodyRef?: React.RefObject<HTMLDivElement | null>;
|
||||
cuts: PublicCut[];
|
||||
onStartSession: (cutId: string, shiftId?: string) => void;
|
||||
sessionActive?: boolean;
|
||||
|
||||
@ -140,23 +140,31 @@ export default function EmailTemplateEditor({
|
||||
const processTemplate = (content: string, data: Record<string, string>): string => {
|
||||
let processed = content;
|
||||
|
||||
// Process VIDEO variables first (render as video player in preview)
|
||||
// Process VIDEO variables first (render as card preview)
|
||||
if (template) {
|
||||
template.variables.forEach((variable) => {
|
||||
if (variable.type === 'VIDEO' && variable.videoId) {
|
||||
const mediaApiUrl = import.meta.env.VITE_MEDIA_API_URL || 'http://localhost:4100';
|
||||
const thumbUrl = `${mediaApiUrl}/api/videos/${variable.videoId}/thumbnail`;
|
||||
const videoHtml = `
|
||||
<div style="max-width: 600px; margin: 20px auto; text-align: center;">
|
||||
<video
|
||||
controls
|
||||
style="width: 100%; max-width: 600px; border-radius: 8px;"
|
||||
src="${mediaApiUrl}/api/videos/${variable.videoId}/stream"
|
||||
poster="${mediaApiUrl}/api/videos/${variable.videoId}/thumbnail"
|
||||
>
|
||||
Your browser does not support video playback.
|
||||
</video>
|
||||
<p style="margin-top: 8px; font-size: 12px; color: #999;">
|
||||
⚠️ In actual emails, this will display as a thumbnail with a link
|
||||
<div style="max-width: 480px; margin: 16px auto;">
|
||||
<div style="border-radius: 8px; overflow: hidden; background: #1b2838;">
|
||||
<div style="position: relative;">
|
||||
<img src="${thumbUrl}" alt="Video thumbnail" style="width: 100%; display: block;" />
|
||||
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 48px; height: 48px; background: rgba(0,0,0,0.5); border-radius: 50%; display: flex; align-items: center; justify-content: center;">
|
||||
<span style="color: #fff; font-size: 20px;">▶</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding: 12px 16px;">
|
||||
<div style="color: #fff; font-size: 15px; font-weight: 600;">Video #${variable.videoId}</div>
|
||||
<div style="color: #8899aa; font-size: 12px; margin-top: 4px;">Duration • Quality • Views</div>
|
||||
<div style="margin-top: 10px; text-align: center;">
|
||||
<span style="display: inline-block; padding: 8px 20px; background: #9d4edd; color: #fff; border-radius: 6px; font-size: 13px; font-weight: 600;">▶ Watch Video</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p style="margin-top: 6px; font-size: 11px; color: #999; text-align: center;">
|
||||
Recipients see a thumbnail card with a "Watch Video" button
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@ -114,7 +114,6 @@ function FullscreenInvalidator() {
|
||||
}
|
||||
|
||||
function MapEventsHandler({
|
||||
onMove,
|
||||
setMapInstance,
|
||||
setCurrentZoom
|
||||
}: {
|
||||
|
||||
@ -1,7 +1,4 @@
|
||||
import { LineChart, Line, AreaChart, Area, BarChart, Bar, PieChart, Pie, Cell, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts';
|
||||
import { theme } from 'antd';
|
||||
|
||||
const { useToken } = theme;
|
||||
|
||||
interface AnalyticsChartProps {
|
||||
type: 'line' | 'area' | 'bar' | 'pie';
|
||||
@ -24,7 +21,6 @@ export default function AnalyticsChart({
|
||||
color = '#1890ff',
|
||||
height = 300,
|
||||
}: AnalyticsChartProps) {
|
||||
const { token } = useToken();
|
||||
|
||||
if (!data || data.length === 0) {
|
||||
return (
|
||||
@ -85,12 +81,12 @@ export default function AnalyticsChart({
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={({ name, percent }) => `${name}: ${(percent * 100).toFixed(0)}%`}
|
||||
label={({ name, percent }) => `${name}: ${((percent ?? 0) * 100).toFixed(0)}%`}
|
||||
outerRadius={80}
|
||||
fill={color}
|
||||
dataKey={dataKey}
|
||||
>
|
||||
{data.map((entry, index) => (
|
||||
{data.map((_entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
|
||||
@ -47,8 +47,8 @@ export default function ExpandedVideoCard({ video }: ExpandedVideoCardProps) {
|
||||
// Parent padding to break out of (matches MediaPublicLayout)
|
||||
const pad = isMobile ? 8 : 12;
|
||||
|
||||
// Extract title from filename
|
||||
const title = video.filename.replace(/\.[^/.]+$/, '');
|
||||
// Use video title if available, fall back to filename without extension
|
||||
const title = video.title || video.filename.replace(/\.[^/.]+$/, '');
|
||||
|
||||
// Keyboard shortcuts
|
||||
useKeyboardShortcuts({
|
||||
|
||||
@ -28,7 +28,6 @@ const REACTION_COLORS: Record<string, string> = {
|
||||
export default function ProgressBarMarkers({
|
||||
videoId,
|
||||
durationSeconds,
|
||||
playerRef,
|
||||
}: ProgressBarMarkersProps) {
|
||||
const { token } = theme.useToken();
|
||||
const [reactions, setReactions] = useState<Reaction[]>([]);
|
||||
|
||||
@ -9,6 +9,7 @@ interface PublicVideoCardProps {
|
||||
video: {
|
||||
id: number;
|
||||
filename: string;
|
||||
title: string | null;
|
||||
category: string | null;
|
||||
durationSeconds: number | null;
|
||||
quality: string | null;
|
||||
@ -51,8 +52,8 @@ export default function PublicVideoCard({ video }: PublicVideoCardProps) {
|
||||
return count.toString();
|
||||
};
|
||||
|
||||
// Extract title from filename (remove extension)
|
||||
const title = video.filename.replace(/\.[^/.]+$/, '');
|
||||
// Use video title if available, fall back to filename without extension
|
||||
const title = video.title || video.filename.replace(/\.[^/.]+$/, '');
|
||||
|
||||
// Cleanup hover timeout on unmount
|
||||
useEffect(() => {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Modal, Spin, Statistic, Row, Col, Empty, Tag, Button, Alert, Skeleton } from 'antd';
|
||||
import { Modal, Statistic, Row, Col, Empty, Tag, Button, Alert, Skeleton } from 'antd';
|
||||
import {
|
||||
EyeOutlined,
|
||||
UserOutlined,
|
||||
|
||||
@ -262,7 +262,7 @@ export default function SchedulePublishModal({
|
||||
onChange={setUnpublishAt}
|
||||
disabledDate={(current) => {
|
||||
// Unpublish must be after publish
|
||||
return current && (current < dayjs().startOf('day') || (publishAt && current <= publishAt));
|
||||
return !!(current && (current < dayjs().startOf('day') || (publishAt && current <= publishAt)));
|
||||
}}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="Select unpublish date and time"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Drawer,
|
||||
Upload,
|
||||
Form,
|
||||
Input,
|
||||
@ -10,6 +10,7 @@ import {
|
||||
Typography,
|
||||
List,
|
||||
Tag,
|
||||
Button,
|
||||
} from 'antd';
|
||||
import { InboxOutlined, CheckCircleOutlined, CloseCircleOutlined } from '@ant-design/icons';
|
||||
import type { UploadFile } from 'antd/es/upload/interface';
|
||||
@ -18,7 +19,7 @@ import { mediaApi } from '@/lib/media-api';
|
||||
const { Dragger } = Upload;
|
||||
const { Text } = Typography;
|
||||
|
||||
interface UploadVideoModalProps {
|
||||
interface UploadVideoDrawerProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
@ -30,7 +31,7 @@ interface UploadResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export default function UploadVideoModal({ open, onClose, onSuccess }: UploadVideoModalProps) {
|
||||
export default function UploadVideoDrawer({ open, onClose, onSuccess }: UploadVideoDrawerProps) {
|
||||
const [form] = Form.useForm();
|
||||
const [fileList, setFileList] = useState<UploadFile[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
@ -143,16 +144,34 @@ export default function UploadVideoModal({ open, onClose, onSuccess }: UploadVid
|
||||
const failCount = results.length - successCount;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
<Drawer
|
||||
title="Upload Videos"
|
||||
open={open}
|
||||
onCancel={handleClose}
|
||||
onOk={handleUpload}
|
||||
okText={uploading ? 'Uploading...' : 'Upload'}
|
||||
okButtonProps={{ disabled: fileList.length === 0 || uploading }}
|
||||
cancelButtonProps={{ disabled: uploading }}
|
||||
width={700}
|
||||
destroyOnHidden
|
||||
onClose={handleClose}
|
||||
placement="right"
|
||||
width={520}
|
||||
mask={false}
|
||||
destroyOnClose
|
||||
closable={!uploading}
|
||||
styles={{
|
||||
wrapper: { top: 64, height: 'calc(100vh - 64px)' },
|
||||
body: { padding: 24, overflowY: 'auto' },
|
||||
}}
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={handleClose} disabled={uploading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={handleUpload}
|
||||
disabled={fileList.length === 0 || uploading}
|
||||
loading={uploading}
|
||||
>
|
||||
{uploading ? 'Uploading...' : 'Upload'}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
{!showResults && (
|
||||
@ -232,7 +251,7 @@ export default function UploadVideoModal({ open, onClose, onSuccess }: UploadVid
|
||||
renderItem={(result) => (
|
||||
<List.Item>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<Text ellipsis style={{ maxWidth: 400 }}>
|
||||
<Text ellipsis style={{ maxWidth: 340 }}>
|
||||
{result.filename}
|
||||
</Text>
|
||||
{result.success ? (
|
||||
@ -258,6 +277,6 @@ export default function UploadVideoModal({ open, onClose, onSuccess }: UploadVid
|
||||
</Space>
|
||||
)}
|
||||
</Space>
|
||||
</Modal>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@ -5,7 +5,6 @@ import {
|
||||
PlayCircleOutlined,
|
||||
BarChartOutlined,
|
||||
CopyOutlined,
|
||||
SwapOutlined,
|
||||
DownloadOutlined,
|
||||
PictureOutlined,
|
||||
ReloadOutlined,
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Modal, Tabs, Spin, Statistic, Row, Col, Empty, Card, Table, Alert, Button, Skeleton } from 'antd';
|
||||
import { Modal, Tabs, Statistic, Row, Col, Empty, Card, Table, Alert, Button, Skeleton } from 'antd';
|
||||
import {
|
||||
EyeOutlined,
|
||||
UserOutlined,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Card, Checkbox, Tag, Spin } from 'antd';
|
||||
import { ClockCircleOutlined, PlayCircleOutlined, CheckCircleOutlined, ThunderboltFilled } from '@ant-design/icons';
|
||||
import { ClockCircleOutlined, CheckCircleOutlined, ThunderboltFilled } from '@ant-design/icons';
|
||||
import { useState } from 'react';
|
||||
import type { Video } from '@/types/media';
|
||||
import { getAuthCallbacks } from '@/lib/api';
|
||||
|
||||
@ -94,7 +94,7 @@ export const VideoPickerModal: React.FC<VideoPickerModalProps> = ({
|
||||
if (orientation) params.append('orientation', orientation);
|
||||
if (producer) params.append('producers', producer);
|
||||
|
||||
const response = await mediaApi.get(`/api/videos?${params.toString()}`);
|
||||
const response = await mediaApi.get(`/videos?${params.toString()}`);
|
||||
setVideos(response.data.videos || []);
|
||||
setTotal(response.data.total || 0);
|
||||
} catch (error) {
|
||||
@ -107,7 +107,7 @@ export const VideoPickerModal: React.FC<VideoPickerModalProps> = ({
|
||||
|
||||
const fetchProducers = async () => {
|
||||
try {
|
||||
const response = await mediaApi.get('/api/videos/producers');
|
||||
const response = await mediaApi.get('/videos/producers');
|
||||
setProducers(response.data || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch producers:', error);
|
||||
@ -163,7 +163,9 @@ export const VideoPickerModal: React.FC<VideoPickerModalProps> = ({
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const mediaApiUrl = import.meta.env.VITE_MEDIA_API_URL || 'http://localhost:4100';
|
||||
// Use Vite proxy path for browser-facing URLs (img src, etc.)
|
||||
// The raw VITE_MEDIA_API_URL may be a Docker hostname the browser can't resolve
|
||||
const mediaThumbnailBase = '/media';
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@ -275,7 +277,7 @@ export const VideoPickerModal: React.FC<VideoPickerModalProps> = ({
|
||||
>
|
||||
{video.thumbnailPath ? (
|
||||
<img
|
||||
src={`${mediaApiUrl}/api/videos/${video.id}/thumbnail`}
|
||||
src={`${mediaThumbnailBase}/videos/${video.id}/thumbnail`}
|
||||
alt={video.title}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect, useRef, useImperativeHandle, forwardRef } from 'react';
|
||||
import { useState, useEffect, useRef, useImperativeHandle, forwardRef } from 'react';
|
||||
import { Alert, Spin } from 'antd';
|
||||
import { PlayCircleOutlined } from '@ant-design/icons';
|
||||
import { getAuthCallbacks } from '@/lib/api';
|
||||
|
||||
@ -22,7 +22,7 @@ interface VideoViewerModalProps {
|
||||
export default function VideoViewerModal({ video, open, onClose }: VideoViewerModalProps) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [viewId, setViewId] = useState<number | null>(null);
|
||||
const heartbeatInterval = useRef<NodeJS.Timeout | null>(null);
|
||||
const heartbeatInterval = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const lastWatchTime = useRef<number>(0);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@ -27,7 +27,7 @@ export default function ViewersTable({ viewers, loading }: ViewersTableProps) {
|
||||
title: 'User',
|
||||
dataIndex: 'userName',
|
||||
key: 'userName',
|
||||
render: (name: string | null, record: any) => name || <i style={{ color: '#999' }}>No name</i>,
|
||||
render: (name: string | null) => name || <i style={{ color: '#999' }}>No name</i>,
|
||||
},
|
||||
{
|
||||
title: 'Email',
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { Calendar, Badge, Tag, Spin } from 'antd';
|
||||
import type { CalendarProps } from 'antd';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import type { Shift } from '@/types/api';
|
||||
|
||||
interface Props {
|
||||
|
||||
@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
export interface VideoData {
|
||||
id: number;
|
||||
filename: string;
|
||||
title: string | null;
|
||||
category: string | null;
|
||||
durationSeconds: number | null;
|
||||
quality: string | null;
|
||||
|
||||
195
admin/src/hooks/useDocsSearch.ts
Normal file
195
admin/src/hooks/useDocsSearch.ts
Normal file
@ -0,0 +1,195 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import MiniSearch from 'minisearch';
|
||||
|
||||
// ---- Types ----
|
||||
|
||||
interface MkDocsIndexDoc {
|
||||
location: string;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface MkDocsSearchIndex {
|
||||
docs: MkDocsIndexDoc[];
|
||||
}
|
||||
|
||||
export interface DocSearchResult {
|
||||
title: string;
|
||||
snippet: string;
|
||||
location: string;
|
||||
docUrl: string;
|
||||
qrUrl: string;
|
||||
}
|
||||
|
||||
// ---- Module-level cache (survives unmount/remount) ----
|
||||
|
||||
let cachedIndex: MiniSearch | null = null;
|
||||
let cachedDocs: MkDocsIndexDoc[] = [];
|
||||
let indexPromise: Promise<void> | null = null;
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
function stripHtml(html: string): string {
|
||||
return html.replace(/<[^>]*>/g, '');
|
||||
}
|
||||
|
||||
function isLocalDev(): boolean {
|
||||
const host = window.location.hostname;
|
||||
return host === 'localhost' || host === '127.0.0.1' || host.startsWith('192.168.');
|
||||
}
|
||||
|
||||
function getDocsBaseUrl(): string {
|
||||
if (isLocalDev()) {
|
||||
return `http://localhost:${import.meta.env.VITE_MKDOCS_PORT || '4003'}`;
|
||||
}
|
||||
const domain = window.location.hostname.replace(/^app\./, '');
|
||||
return `${window.location.protocol}//docs.${domain}`;
|
||||
}
|
||||
|
||||
function getSearchIndexUrl(): string {
|
||||
// Always use same-origin proxy path (Vite proxy in dev, nginx in production)
|
||||
return '/mkdocs-proxy/search/search_index.json';
|
||||
}
|
||||
|
||||
function getDocPageUrl(location: string): string {
|
||||
const base = getDocsBaseUrl();
|
||||
return `${base}/${location}`;
|
||||
}
|
||||
|
||||
function extractSnippet(text: string, query: string, maxLength = 150): string {
|
||||
const lowerText = text.toLowerCase();
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const index = lowerText.indexOf(lowerQuery);
|
||||
|
||||
if (index === -1) {
|
||||
return text.substring(0, maxLength) + (text.length > maxLength ? '...' : '');
|
||||
}
|
||||
|
||||
const start = Math.max(0, index - 50);
|
||||
const end = Math.min(text.length, index + query.length + 100);
|
||||
let snippet = text.substring(start, end);
|
||||
|
||||
if (start > 0) snippet = '...' + snippet;
|
||||
if (end < text.length) snippet = snippet + '...';
|
||||
|
||||
return snippet;
|
||||
}
|
||||
|
||||
// ---- Hook ----
|
||||
|
||||
export function useDocsSearch() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [results, setResults] = useState<DocSearchResult[]>([]);
|
||||
const [indexReady, setIndexReady] = useState(!!cachedIndex);
|
||||
|
||||
const loadIndex = useCallback(async () => {
|
||||
// Already cached
|
||||
if (cachedIndex) {
|
||||
setIndexReady(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Already loading — wait for existing promise
|
||||
if (indexPromise) {
|
||||
setLoading(true);
|
||||
try {
|
||||
await indexPromise;
|
||||
setIndexReady(true);
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to load docs index');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
indexPromise = (async () => {
|
||||
const url = getSearchIndexUrl();
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`Failed to fetch docs index (${res.status})`);
|
||||
const data: MkDocsSearchIndex = await res.json();
|
||||
|
||||
const idx = new MiniSearch<MkDocsIndexDoc>({
|
||||
fields: ['title', 'text'],
|
||||
storeFields: ['title', 'text', 'location'],
|
||||
searchOptions: {
|
||||
boost: { title: 10 },
|
||||
prefix: true,
|
||||
fuzzy: 0.2,
|
||||
},
|
||||
});
|
||||
|
||||
const docs = data.docs
|
||||
.filter((d) => d.title && d.location)
|
||||
.map((d, i) => ({
|
||||
id: i,
|
||||
title: stripHtml(d.title),
|
||||
text: stripHtml(d.text),
|
||||
location: d.location,
|
||||
}));
|
||||
|
||||
idx.addAll(docs);
|
||||
cachedDocs = data.docs.map((d) => ({
|
||||
title: stripHtml(d.title),
|
||||
text: stripHtml(d.text),
|
||||
location: d.location,
|
||||
}));
|
||||
cachedIndex = idx;
|
||||
})();
|
||||
|
||||
try {
|
||||
await indexPromise;
|
||||
setIndexReady(true);
|
||||
} catch (e) {
|
||||
indexPromise = null; // Allow retry
|
||||
const msg = e instanceof Error ? e.message : 'Failed to load docs index';
|
||||
setError(msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const search = useCallback(
|
||||
(query: string) => {
|
||||
if (!cachedIndex || !query || query.length < 2) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const raw = cachedIndex.search(query).slice(0, 10);
|
||||
|
||||
const mapped: DocSearchResult[] = raw
|
||||
.filter((r) => cachedDocs[r.id])
|
||||
.map((r) => {
|
||||
const doc = cachedDocs[r.id]!;
|
||||
const docUrl = getDocPageUrl(doc.location);
|
||||
return {
|
||||
title: doc.title,
|
||||
snippet: extractSnippet(doc.text, query),
|
||||
location: doc.location,
|
||||
docUrl,
|
||||
qrUrl: `/api/qr?text=${encodeURIComponent(docUrl)}&size=150`,
|
||||
};
|
||||
});
|
||||
|
||||
setResults(mapped);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const retry = useCallback(() => {
|
||||
cachedIndex = null;
|
||||
cachedDocs = [];
|
||||
indexPromise = null;
|
||||
setError(null);
|
||||
setIndexReady(false);
|
||||
loadIndex();
|
||||
}, [loadIndex]);
|
||||
|
||||
return { loading, error, indexReady, results, loadIndex, search, retry };
|
||||
}
|
||||
@ -68,7 +68,7 @@ api.interceptors.response.use(
|
||||
}
|
||||
|
||||
const data = await refreshPromise;
|
||||
onTokenRefresh(data.accessToken, data.refreshToken);
|
||||
onTokenRefresh(data.accessToken!, data.refreshToken!);
|
||||
|
||||
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
|
||||
return api(originalRequest);
|
||||
|
||||
@ -66,7 +66,7 @@ mediaApi.interceptors.response.use(
|
||||
const data = await mediaRefreshPromise;
|
||||
|
||||
// Update tokens in auth store via callback
|
||||
callbacks.onTokenRefresh(data.accessToken, data.refreshToken);
|
||||
callbacks.onTokenRefresh(data.accessToken!, data.refreshToken!);
|
||||
|
||||
// Retry original request with new token
|
||||
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
|
||||
|
||||
@ -2,7 +2,7 @@ import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Row, Col, Card, Statistic, Typography, Tag, Badge, Button,
|
||||
Progress, Space, Tooltip, Spin, Grid, Flex,
|
||||
Progress, Space, Tooltip, Spin, Grid, Flex, Segmented,
|
||||
} from 'antd';
|
||||
import {
|
||||
TeamOutlined,
|
||||
@ -35,6 +35,7 @@ import {
|
||||
CloseCircleFilled,
|
||||
MinusCircleFilled,
|
||||
IdcardOutlined,
|
||||
HomeOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
BarChart, Bar, XAxis, YAxis, Tooltip as RechartsTooltip,
|
||||
@ -50,8 +51,9 @@ import RequestTrafficChart from '@/components/dashboard/RequestTrafficChart';
|
||||
import LatencyBandsChart from '@/components/dashboard/LatencyBandsChart';
|
||||
import ContainerPopover from '@/components/dashboard/ContainerPopover';
|
||||
import ContainerMemoryChart from '@/components/dashboard/ContainerMemoryChart';
|
||||
import { buildServiceUrl } from '@/lib/service-url';
|
||||
import type {
|
||||
DashboardSummary, QueueStats, ServicesStatus,
|
||||
DashboardSummary, QueueStats, ServicesStatus, ServicesConfig,
|
||||
SystemInfo, ContainerInfo, WeatherData, ApiMetrics,
|
||||
TimeSeriesResult, ContainerResource, ContainerResourcesResponse,
|
||||
} from '@/types/api';
|
||||
@ -164,6 +166,8 @@ export default function DashboardPage() {
|
||||
const [containerResources, setContainerResources] = useState<ContainerResource[] | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [lastRefresh, setLastRefresh] = useState<Date | null>(null);
|
||||
const [activeView, setActiveView] = useState<'dashboard' | 'homepage'>('dashboard');
|
||||
const [homepageUrl, setHomepageUrl] = useState<string | null>(null);
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval>>(undefined);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
@ -179,6 +183,9 @@ export default function DashboardPage() {
|
||||
if (isSuperAdmin) {
|
||||
promises.push(
|
||||
api.get<ServicesStatus>('/services/status').then(({ data }) => setServices(data)).catch(() => {}),
|
||||
api.get<ServicesConfig>('/services/config').then(({ data }) => {
|
||||
setHomepageUrl(buildServiceUrl(data.homepageSubdomain, data.domain, data.homepagePort));
|
||||
}).catch(() => {}),
|
||||
api.get<SystemInfo>('/dashboard/system').then(({ data }) => setSystemInfo(data)).catch(() => {}),
|
||||
api.get<ContainerInfo[]>('/dashboard/containers').then(({ data }) => setContainers(data)).catch(() => {}),
|
||||
api.get<ApiMetrics>('/dashboard/api-metrics').then(({ data }) => {
|
||||
@ -267,43 +274,78 @@ export default function DashboardPage() {
|
||||
styles={{ body: { padding: screens.md ? '20px 24px' : '16px' } }}
|
||||
>
|
||||
<Flex justify="space-between" align="center" wrap="wrap" gap={12}>
|
||||
<div>
|
||||
<Title level={4} style={{ color: '#fff', margin: 0 }}>
|
||||
Welcome{user?.name ? `, ${user.name}` : ''}
|
||||
</Title>
|
||||
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 12 }}>
|
||||
{lastRefresh && `Updated ${lastRefresh.toLocaleTimeString()}`}
|
||||
</Text>
|
||||
</div>
|
||||
<Flex gap={6} wrap="wrap" justify="flex-end">
|
||||
{showInfluence && (
|
||||
<Button size="small" icon={<PlusOutlined />} onClick={() => navigate('/app/campaigns')}>Campaign</Button>
|
||||
<Flex align="center" gap={16} wrap="wrap">
|
||||
<div>
|
||||
<Title level={4} style={{ color: '#fff', margin: 0 }}>
|
||||
Welcome{user?.name ? `, ${user.name}` : ''}
|
||||
</Title>
|
||||
<Text style={{ color: 'rgba(255,255,255,0.7)', fontSize: 12 }}>
|
||||
{lastRefresh && `Updated ${lastRefresh.toLocaleTimeString()}`}
|
||||
</Text>
|
||||
</div>
|
||||
{isSuperAdmin && homepageUrl && (
|
||||
<Segmented
|
||||
value={activeView}
|
||||
onChange={(val) => setActiveView(val as 'dashboard' | 'homepage')}
|
||||
options={[
|
||||
{ label: 'Dashboard', value: 'dashboard', icon: <DashboardOutlined /> },
|
||||
{ label: 'Homepage', value: 'homepage', icon: <HomeOutlined /> },
|
||||
]}
|
||||
style={{ background: 'rgba(255,255,255,0.2)', borderRadius: 6 }}
|
||||
/>
|
||||
)}
|
||||
{showMap && (
|
||||
<Button size="small" icon={<EnvironmentOutlined />} onClick={() => navigate('/app/map')}>Location</Button>
|
||||
)}
|
||||
{showMedia && (
|
||||
<Button size="small" icon={<UploadOutlined />} onClick={() => navigate('/app/media/library')}>Video</Button>
|
||||
)}
|
||||
<Button size="small" icon={<FileTextOutlined />} onClick={() => navigate('/app/pages')}>Page</Button>
|
||||
{isSuperAdmin && (
|
||||
<>
|
||||
<Button size="small" icon={<BarChartOutlined />} onClick={() => navigate('/app/observability')}>Monitoring</Button>
|
||||
<Button size="small" icon={<CloudServerOutlined />} onClick={() => navigate('/app/tunnel')}>Tunnel</Button>
|
||||
<Button size="small" icon={<DatabaseOutlined />} onClick={() => navigate('/app/services/nocodb')}>NocoDB</Button>
|
||||
<Button size="small" icon={<BranchesOutlined />} onClick={() => navigate('/app/services/n8n')}>Workflows</Button>
|
||||
<Button size="small" icon={<GlobalOutlined />} onClick={() => navigate('/app/services/gitea')}>Git</Button>
|
||||
<Button size="small" icon={<CodeOutlined />} onClick={() => navigate('/app/code')}>Code</Button>
|
||||
<Button size="small" icon={<BookOutlined />} onClick={() => navigate('/app/docs')}>Docs</Button>
|
||||
<Button size="small" icon={<QrcodeOutlined />} onClick={() => navigate('/app/services/miniqr')}>QR</Button>
|
||||
<Button size="small" icon={<DashboardOutlined />} onClick={() => navigate('/app/map/data-quality')}>Data Quality</Button>
|
||||
</>
|
||||
)}
|
||||
<Button size="small" icon={<ReloadOutlined spin={loading} />} onClick={handleRefresh}>Refresh</Button>
|
||||
</Flex>
|
||||
{activeView === 'dashboard' && (
|
||||
<Flex gap={6} wrap="wrap" justify="flex-end">
|
||||
{showInfluence && (
|
||||
<Button size="small" icon={<PlusOutlined />} onClick={() => navigate('/app/campaigns')}>Campaign</Button>
|
||||
)}
|
||||
{showMap && (
|
||||
<Button size="small" icon={<EnvironmentOutlined />} onClick={() => navigate('/app/map')}>Location</Button>
|
||||
)}
|
||||
{showMedia && (
|
||||
<Button size="small" icon={<UploadOutlined />} onClick={() => navigate('/app/media/library')}>Video</Button>
|
||||
)}
|
||||
<Button size="small" icon={<FileTextOutlined />} onClick={() => navigate('/app/pages')}>Page</Button>
|
||||
{isSuperAdmin && (
|
||||
<>
|
||||
<Button size="small" icon={<BarChartOutlined />} onClick={() => navigate('/app/observability')}>Monitoring</Button>
|
||||
<Button size="small" icon={<CloudServerOutlined />} onClick={() => navigate('/app/tunnel')}>Tunnel</Button>
|
||||
<Button size="small" icon={<DatabaseOutlined />} onClick={() => navigate('/app/services/nocodb')}>NocoDB</Button>
|
||||
<Button size="small" icon={<BranchesOutlined />} onClick={() => navigate('/app/services/n8n')}>Workflows</Button>
|
||||
<Button size="small" icon={<GlobalOutlined />} onClick={() => navigate('/app/services/gitea')}>Git</Button>
|
||||
<Button size="small" icon={<CodeOutlined />} onClick={() => navigate('/app/code')}>Code</Button>
|
||||
<Button size="small" icon={<BookOutlined />} onClick={() => navigate('/app/docs')}>Docs</Button>
|
||||
<Button size="small" icon={<QrcodeOutlined />} onClick={() => navigate('/app/services/miniqr')}>QR</Button>
|
||||
<Button size="small" icon={<DashboardOutlined />} onClick={() => navigate('/app/map/data-quality')}>Data Quality</Button>
|
||||
</>
|
||||
)}
|
||||
<Button size="small" icon={<ReloadOutlined spin={loading} />} onClick={handleRefresh}>Refresh</Button>
|
||||
</Flex>
|
||||
)}
|
||||
{activeView === 'homepage' && (
|
||||
<Button size="small" icon={<ReloadOutlined spin={loading} />} onClick={handleRefresh}>Refresh</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</Card>
|
||||
|
||||
{/* === Homepage iframe view === */}
|
||||
{activeView === 'homepage' && homepageUrl && (
|
||||
<iframe
|
||||
src={homepageUrl}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: 'calc(100vh - 160px)',
|
||||
border: 'none',
|
||||
borderRadius: 8,
|
||||
}}
|
||||
title="Homepage Dashboard"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* === Dashboard view === */}
|
||||
{activeView === 'dashboard' && <>
|
||||
|
||||
{/* === Weather + Key Metrics Row === */}
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
|
||||
{weather && (
|
||||
@ -747,6 +789,8 @@ export default function DashboardPage() {
|
||||
|
||||
</>
|
||||
)}
|
||||
|
||||
</>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -816,6 +860,7 @@ const SERVICE_LABELS: Record<string, string> = {
|
||||
mailhog: 'MailHog',
|
||||
miniqr: 'Mini QR',
|
||||
excalidraw: 'Excalidraw',
|
||||
homepage: 'Homepage',
|
||||
};
|
||||
|
||||
const SERVICE_ICONS: Record<string, React.ReactNode> = {
|
||||
@ -825,6 +870,7 @@ const SERVICE_ICONS: Record<string, React.ReactNode> = {
|
||||
mailhog: <MailOutlined />,
|
||||
miniqr: <QrcodeOutlined />,
|
||||
excalidraw: <FileTextOutlined />,
|
||||
homepage: <HomeOutlined />,
|
||||
};
|
||||
|
||||
function ServiceBadge({ name, online, icon }: {
|
||||
|
||||
@ -51,6 +51,7 @@ import {
|
||||
QuestionCircleOutlined,
|
||||
UploadOutlined,
|
||||
InboxOutlined,
|
||||
PlayCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import Editor from '@monaco-editor/react';
|
||||
import type { OnMount } from '@monaco-editor/react';
|
||||
@ -60,6 +61,9 @@ import { buildServiceUrl } from '@/lib/service-url';
|
||||
import { useMkDocsBuild } from '@/hooks/useMkDocsBuild';
|
||||
import type { FileNode, ServicesConfig } from '@/types/api';
|
||||
import type { AppOutletContext } from '@/components/AppLayout';
|
||||
import { VideoPickerModal } from '@/components/media/VideoPickerModal';
|
||||
import type { Video as PickerVideo } from '@/components/media/VideoPickerModal';
|
||||
import { generateVideoCardHtml } from '@/utils/videoCardHtml';
|
||||
|
||||
type LayoutMode = 'split' | 'editor' | 'preview';
|
||||
|
||||
@ -308,6 +312,7 @@ const SNIPPETS: MkDocsSnippet[] = [
|
||||
{ id: 'math-block', label: 'Math Block', group: 'insert', type: 'block', template: '$$\n$CURSOR\n$$' },
|
||||
{ id: 'footnote', label: 'Footnote', group: 'insert', type: 'insert', template: '[^1]\n\n[^1]: Text' },
|
||||
{ id: 'def-list', label: 'Definition List', group: 'insert', type: 'insert', template: 'Term\n: Definition' },
|
||||
{ id: 'video-card', label: 'Video Card', group: 'insert', type: 'insert', template: '' },
|
||||
{ id: 'hr', label: 'Horizontal Rule', group: 'insert', type: 'insert', template: '---' },
|
||||
];
|
||||
|
||||
@ -399,6 +404,7 @@ export default function DocsPage() {
|
||||
const [modalInput, setModalInput] = useState('');
|
||||
const [contextPath, setContextPath] = useState<string>('');
|
||||
|
||||
const [videoPickerOpen, setVideoPickerOpen] = useState(false);
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const dragCounter = useRef(0);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
@ -573,11 +579,49 @@ export default function DocsPage() {
|
||||
}, []);
|
||||
|
||||
const handleToolbarSnippet = useCallback((snippetId: string) => {
|
||||
if (snippetId === 'video-card') {
|
||||
setVideoPickerOpen(true);
|
||||
return;
|
||||
}
|
||||
const snippet = SNIPPETS.find(s => s.id === snippetId);
|
||||
if (!snippet || !monacoEditorRef.current || !monacoRef.current) return;
|
||||
applySnippet(monacoEditorRef.current, snippet, monacoRef.current);
|
||||
}, []);
|
||||
|
||||
const handleVideoCardInsert = useCallback((video: PickerVideo) => {
|
||||
const adminUrl = config
|
||||
? `${window.location.protocol}//${config.domain.replace(/^([^.]+)/, 'app')}`
|
||||
: window.location.origin;
|
||||
|
||||
// Use a placeholder thumbnail — video-player.js will hydrate with
|
||||
// the correct MEDIA_API_URL at runtime (avoids Docker hostname issues)
|
||||
const placeholderThumb = 'data:image/svg+xml,' + encodeURIComponent(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="480" height="270" viewBox="0 0 480 270">' +
|
||||
'<rect fill="#0d1b2a" width="480" height="270"/>' +
|
||||
'<circle cx="240" cy="135" r="32" fill="rgba(157,78,221,0.6)"/>' +
|
||||
'<polygon points="230,118 258,135 230,152" fill="#fff"/>' +
|
||||
'</svg>'
|
||||
);
|
||||
|
||||
const html = generateVideoCardHtml({
|
||||
id: video.id,
|
||||
title: video.title,
|
||||
durationSeconds: video.durationSeconds || 0,
|
||||
quality: '',
|
||||
viewCount: 0,
|
||||
thumbnailUrl: placeholderThumb,
|
||||
}, { baseUrl: adminUrl });
|
||||
|
||||
const ed = monacoEditorRef.current;
|
||||
if (ed) {
|
||||
const sel = ed.getSelection();
|
||||
if (sel) {
|
||||
ed.executeEdits('video-card-insert', [{ range: sel, text: '\n' + html + '\n' }]);
|
||||
}
|
||||
}
|
||||
setVideoPickerOpen(false);
|
||||
}, [config]);
|
||||
|
||||
const handleCtxMenuClick = useCallback((snippetId: string) => {
|
||||
setCtxMenu(null);
|
||||
handleToolbarSnippet(snippetId);
|
||||
@ -1496,7 +1540,7 @@ export default function DocsPage() {
|
||||
<Dropdown menu={{ items: SNIPPETS.filter(s => s.group === 'insert').map(s => ({
|
||||
key: s.id,
|
||||
label: s.label,
|
||||
icon: s.id === 'link' ? <LinkOutlined /> : s.id === 'image' ? <PictureOutlined /> : s.id === 'table' ? <TableOutlined /> : <PlusOutlined />,
|
||||
icon: s.id === 'video-card' ? <PlayCircleOutlined /> : s.id === 'link' ? <LinkOutlined /> : s.id === 'image' ? <PictureOutlined /> : s.id === 'table' ? <TableOutlined /> : <PlusOutlined />,
|
||||
onClick: () => handleToolbarSnippet(s.id),
|
||||
})) }} trigger={['click']}>
|
||||
<Button type="text" size="small" style={{ height: 24, fontSize: 12 }}>
|
||||
@ -1645,6 +1689,14 @@ export default function DocsPage() {
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Video Card Picker Modal */}
|
||||
<VideoPickerModal
|
||||
open={videoPickerOpen}
|
||||
onClose={() => setVideoPickerOpen(false)}
|
||||
onSelect={handleVideoCardInsert}
|
||||
title="Insert Video Card"
|
||||
/>
|
||||
|
||||
{/* Custom right-click context menu with submenus */}
|
||||
{ctxMenu && (
|
||||
<div
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import {
|
||||
Card, Button, Form, Input, Space, Table, Tag, Typography, Spin, Alert, Descriptions, Popconfirm, App,
|
||||
Card, Button, Form, Input, Space, Table, Tag, Typography, Spin, Alert, Descriptions, App,
|
||||
Modal, Checkbox, Select,
|
||||
} from 'antd';
|
||||
import {
|
||||
CloudServerOutlined, SyncOutlined, DeleteOutlined, CheckCircleOutlined, CloseCircleOutlined,
|
||||
CloudServerOutlined, SyncOutlined, CheckCircleOutlined, CloseCircleOutlined,
|
||||
RocketOutlined, CopyOutlined, EyeOutlined, EyeInvisibleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { api } from '@/lib/api';
|
||||
@ -72,7 +72,6 @@ export default function PangolinPage() {
|
||||
|
||||
const [status, setStatus] = useState<PangolinStatus | null>(null);
|
||||
const [config, setConfig] = useState<PangolinConfig | null>(null);
|
||||
const [resources, setResources] = useState<PangolinResource[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [setupResult, setSetupResult] = useState<Record<string, unknown> | null>(null);
|
||||
@ -115,8 +114,7 @@ export default function PangolinPage() {
|
||||
|
||||
if (statusRes.data.configured) {
|
||||
try {
|
||||
const resourcesRes = await api.get<{ resources: PangolinResource[] }>('/pangolin/resources');
|
||||
setResources(resourcesRes.data.resources);
|
||||
await api.get<{ resources: PangolinResource[] }>('/pangolin/resources');
|
||||
} catch {
|
||||
// Resources may not load if site isn't set up
|
||||
}
|
||||
@ -212,103 +210,6 @@ export default function PangolinPage() {
|
||||
};
|
||||
|
||||
|
||||
const handleSync = async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
const res = await api.post<{
|
||||
created: number;
|
||||
updated: number;
|
||||
skipped: number;
|
||||
warnings: number;
|
||||
errors: number;
|
||||
details?: {
|
||||
created: string[];
|
||||
updated: string[];
|
||||
skipped: string[];
|
||||
warnings: string[];
|
||||
errors: string[];
|
||||
};
|
||||
}>('/pangolin/sync');
|
||||
|
||||
// Enhanced success message with all details
|
||||
const parts: string[] = [];
|
||||
if (res.data.created > 0) parts.push(`${res.data.created} created`);
|
||||
if (res.data.updated > 0) parts.push(`${res.data.updated} updated`);
|
||||
if (res.data.skipped > 0) parts.push(`${res.data.skipped} skipped`);
|
||||
if (res.data.warnings > 0) parts.push(`${res.data.warnings} warnings`);
|
||||
if (res.data.errors > 0) parts.push(`${res.data.errors} errors`);
|
||||
|
||||
const summary = parts.join(', ') || 'No changes';
|
||||
message.success(`Sync complete: ${summary}`);
|
||||
|
||||
// Show detailed warnings if any
|
||||
if (res.data.details?.warnings && res.data.details.warnings.length > 0) {
|
||||
Modal.info({
|
||||
title: 'Sync Warnings',
|
||||
content: (
|
||||
<div>
|
||||
<p>Some resources were skipped:</p>
|
||||
<ul>
|
||||
{res.data.details.warnings.map((w, i) => (
|
||||
<li key={i}>{w}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
),
|
||||
width: 600,
|
||||
});
|
||||
}
|
||||
|
||||
// Show detailed errors if any
|
||||
if (res.data.details?.errors && res.data.details.errors.length > 0) {
|
||||
Modal.error({
|
||||
title: 'Sync Errors',
|
||||
content: (
|
||||
<div>
|
||||
<p>Some resources failed to sync:</p>
|
||||
<ul>
|
||||
{res.data.details.errors.map((e, i) => (
|
||||
<li key={i}>{e}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
),
|
||||
width: 600,
|
||||
});
|
||||
}
|
||||
|
||||
fetchData();
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { response?: { data?: { error?: { message?: string } } } })?.response?.data?.error?.message || 'Sync failed';
|
||||
message.error(msg);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteResource = async (resourceId: string) => {
|
||||
try {
|
||||
await api.delete(`/pangolin/resource/${resourceId}`);
|
||||
message.success('Resource deleted');
|
||||
fetchData();
|
||||
} catch {
|
||||
message.error('Failed to delete resource');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditResource = (resource: PangolinResource) => {
|
||||
setEditingResource(resource);
|
||||
editForm.setFieldsValue({
|
||||
name: resource.name,
|
||||
ssl: resource.ssl ?? true,
|
||||
active: resource.active ?? true,
|
||||
blockAccess: resource.blockAccess ?? false,
|
||||
proxyPort: resource.proxyPort ?? 80,
|
||||
protocol: resource.protocol ?? 'http',
|
||||
});
|
||||
setEditModalVisible(true);
|
||||
};
|
||||
|
||||
const handleUpdateResource = async (values: Record<string, unknown>) => {
|
||||
if (!editingResource) return;
|
||||
|
||||
|
||||
@ -116,7 +116,7 @@ export default function ShiftsPage() {
|
||||
const [editingSeriesShift, setEditingSeriesShift] = useState<Shift | null>(null);
|
||||
const [calendarData, setCalendarData] = useState<CalendarData['dates']>({});
|
||||
const [calendarLoading, setCalendarLoading] = useState(false);
|
||||
const [currentMonth, setCurrentMonth] = useState(dayjs());
|
||||
const [currentMonth] = useState(dayjs());
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
setSearch(value);
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, Row, Col, Statistic, Table, Spin, Select, Empty, Alert, Button, Skeleton } from 'antd';
|
||||
import { Card, Row, Col, Statistic, Table, Select, Empty, Alert, Button, Skeleton } from 'antd';
|
||||
import {
|
||||
EyeOutlined,
|
||||
VideoCameraOutlined,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Row, Col, Input, Select, Button, Space, Pagination, message, Empty, Spin } from 'antd';
|
||||
import { Row, Col, Input, Select, Button, Pagination, message, Empty, Spin, Tooltip } from 'antd';
|
||||
import {
|
||||
SearchOutlined,
|
||||
GlobalOutlined,
|
||||
@ -22,7 +22,7 @@ import VideoCard from '@/components/media/VideoCard';
|
||||
import BulkActionsBar from '@/components/media/BulkActionsBar';
|
||||
import PublishModal from '@/components/media/PublishModal';
|
||||
import DeleteConfirmModal from '@/components/media/DeleteConfirmModal';
|
||||
import UploadVideoModal from '@/components/media/UploadVideoModal';
|
||||
import UploadVideoDrawer from '@/components/media/UploadVideoDrawer';
|
||||
import VideoViewerModal from '@/components/media/VideoViewerModal';
|
||||
import QuickAnalyticsModal from '@/components/media/QuickAnalyticsModal';
|
||||
import SchedulePublishModal from '@/components/media/SchedulePublishModal';
|
||||
@ -219,98 +219,79 @@ export default function LibraryPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Filters */}
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 16 }}>
|
||||
<Col xs={24} sm={12} md={8}>
|
||||
<Input
|
||||
placeholder="Search videos..."
|
||||
prefix={<SearchOutlined />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
allowClear
|
||||
{/* Toolbar */}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center', marginBottom: 12 }}>
|
||||
<Input
|
||||
placeholder="Search videos..."
|
||||
prefix={<SearchOutlined />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
allowClear
|
||||
style={{ width: 200 }}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Orientation"
|
||||
options={[
|
||||
{ value: 'H', label: 'Horizontal' },
|
||||
{ value: 'V', label: 'Vertical' },
|
||||
]}
|
||||
value={orientation}
|
||||
onChange={setOrientation}
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="Producer"
|
||||
options={producers.map((p) => ({ value: p, label: p }))}
|
||||
value={selectedProducers}
|
||||
onChange={setSelectedProducers}
|
||||
style={{ width: 140 }}
|
||||
maxTagCount={1}
|
||||
/>
|
||||
<Select
|
||||
placeholder="Shorts"
|
||||
options={[
|
||||
{ value: 'true', label: 'Shorts' },
|
||||
{ value: 'false', label: 'Non-Shorts' },
|
||||
]}
|
||||
value={shortsFilter === undefined ? undefined : String(shortsFilter)}
|
||||
onChange={(v) => setShortsFilter(v === undefined ? undefined : v === 'true')}
|
||||
allowClear
|
||||
style={{ width: 110 }}
|
||||
/>
|
||||
<div style={{ flex: 1 }} />
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<UploadOutlined />}
|
||||
onClick={() => setUploadModalOpen(true)}
|
||||
>
|
||||
Upload
|
||||
</Button>
|
||||
<Tooltip title="Fetch from URL">
|
||||
<Button icon={<CloudDownloadOutlined />} onClick={() => setFetchDrawerOpen(true)} />
|
||||
</Tooltip>
|
||||
<Tooltip title="Scan Shorts">
|
||||
<Button icon={<ThunderboltOutlined />} onClick={handleScanShorts} loading={scanningShorts} />
|
||||
</Tooltip>
|
||||
<Tooltip title="Schedule Calendar">
|
||||
<Button icon={<CalendarOutlined />} onClick={() => setCalendarModalOpen(true)} />
|
||||
</Tooltip>
|
||||
<Tooltip title={viewMode === 'grid' ? 'Switch to Compact' : 'Switch to Grid'}>
|
||||
<Button
|
||||
icon={viewMode === 'grid' ? <AppstoreOutlined /> : <BarsOutlined />}
|
||||
onClick={() => setViewMode(viewMode === 'grid' ? 'compact' : 'grid')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={6} md={4}>
|
||||
<Select
|
||||
placeholder="Orientation"
|
||||
options={[
|
||||
{ value: 'H', label: 'Horizontal' },
|
||||
{ value: 'V', label: 'Vertical' },
|
||||
]}
|
||||
value={orientation}
|
||||
onChange={setOrientation}
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={6} md={4}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="Producer"
|
||||
options={producers.map((p) => ({ value: p, label: p }))}
|
||||
value={selectedProducers}
|
||||
onChange={setSelectedProducers}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={6} md={3}>
|
||||
<Select
|
||||
placeholder="Shorts"
|
||||
options={[
|
||||
{ value: 'true', label: 'Shorts Only' },
|
||||
{ value: 'false', label: 'Non-Shorts' },
|
||||
]}
|
||||
value={shortsFilter === undefined ? undefined : String(shortsFilter)}
|
||||
onChange={(v) => setShortsFilter(v === undefined ? undefined : v === 'true')}
|
||||
allowClear
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={9}>
|
||||
<Space wrap>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<UploadOutlined />}
|
||||
onClick={() => setUploadModalOpen(true)}
|
||||
>
|
||||
Upload Videos
|
||||
</Button>
|
||||
<Button
|
||||
icon={<CloudDownloadOutlined />}
|
||||
onClick={() => setFetchDrawerOpen(true)}
|
||||
>
|
||||
Fetch
|
||||
</Button>
|
||||
<Button
|
||||
icon={<ThunderboltOutlined />}
|
||||
onClick={handleScanShorts}
|
||||
loading={scanningShorts}
|
||||
>
|
||||
Scan Shorts
|
||||
</Button>
|
||||
<Button
|
||||
icon={<CalendarOutlined />}
|
||||
onClick={() => setCalendarModalOpen(true)}
|
||||
>
|
||||
Schedule Calendar
|
||||
</Button>
|
||||
<Button
|
||||
icon={viewMode === 'grid' ? <AppstoreOutlined /> : <BarsOutlined />}
|
||||
onClick={() => setViewMode(viewMode === 'grid' ? 'compact' : 'grid')}
|
||||
>
|
||||
{viewMode === 'grid' ? 'Grid' : 'Compact'}
|
||||
</Button>
|
||||
<Button onClick={handleSelectAll}>
|
||||
{selectedVideoIds.length === videos.length ? 'Deselect All' : 'Select All'}
|
||||
</Button>
|
||||
</Space>
|
||||
</Col>
|
||||
</Row>
|
||||
</Tooltip>
|
||||
<Button size="small" type="text" onClick={handleSelectAll}>
|
||||
{selectedVideoIds.length === videos.length && videos.length > 0 ? 'Deselect' : 'Select All'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div style={{ marginBottom: 16, color: '#999' }}>
|
||||
Showing {videos.length} of {pagination.total} videos
|
||||
{selectedVideoIds.length > 0 && ` • ${selectedVideoIds.length} selected`}
|
||||
<div style={{ marginBottom: 8, color: '#999', fontSize: 13 }}>
|
||||
{pagination.total} video{pagination.total !== 1 ? 's' : ''}
|
||||
{selectedVideoIds.length > 0 && ` · ${selectedVideoIds.length} selected`}
|
||||
</div>
|
||||
|
||||
{/* Video Grid */}
|
||||
@ -386,7 +367,7 @@ export default function LibraryPage() {
|
||||
/>
|
||||
|
||||
{/* Modals */}
|
||||
<UploadVideoModal
|
||||
<UploadVideoDrawer
|
||||
open={uploadModalOpen}
|
||||
onClose={() => setUploadModalOpen(false)}
|
||||
onSuccess={handleUploadSuccess}
|
||||
|
||||
@ -121,8 +121,42 @@ export default function PublicLandingPage() {
|
||||
});
|
||||
};
|
||||
|
||||
// Hydrate video cards (static HTML, just refresh metadata)
|
||||
const hydrateVideoCards = async () => {
|
||||
const cards = contentRef.current?.querySelectorAll('.video-card-block');
|
||||
if (!cards) return;
|
||||
|
||||
const mediaApiUrl = import.meta.env.VITE_MEDIA_API_URL || 'http://localhost:4100';
|
||||
|
||||
for (const card of Array.from(cards)) {
|
||||
const videoId = card.getAttribute('data-video-id');
|
||||
if (!videoId || videoId === 'PLACEHOLDER') continue;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${mediaApiUrl}/api/videos/${videoId}/metadata`);
|
||||
if (!res.ok) continue;
|
||||
const meta = await res.json();
|
||||
|
||||
// Update thumbnail
|
||||
const img = card.querySelector('img');
|
||||
if (img && meta.thumbnailUrl) {
|
||||
img.src = meta.thumbnailUrl;
|
||||
}
|
||||
|
||||
// Update title text (first white text div in card body)
|
||||
const titleEl = card.querySelector('div[style*="font-weight: 600"][style*="color: #fff"]') as HTMLElement | null;
|
||||
if (titleEl && meta.title) {
|
||||
titleEl.textContent = meta.title;
|
||||
}
|
||||
} catch {
|
||||
// Silently skip — card keeps its static content
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Hydrate after DOM is ready
|
||||
setTimeout(hydrateVideoBlocks, 100);
|
||||
setTimeout(hydrateVideoCards, 200);
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
|
||||
@ -17,6 +17,7 @@ const { useBreakpoint } = Grid;
|
||||
interface Video {
|
||||
id: number;
|
||||
filename: string;
|
||||
title: string | null;
|
||||
category: string | null;
|
||||
durationSeconds: number | null;
|
||||
quality: string | null;
|
||||
|
||||
@ -32,6 +32,7 @@ const { Title, Text } = Typography;
|
||||
interface Video {
|
||||
id: number;
|
||||
filename: string;
|
||||
title: string | null;
|
||||
category: string;
|
||||
durationSeconds: number | null;
|
||||
quality: string | null;
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Drawer, Spin, App, ConfigProvider, theme, Grid, Typography } from 'antd';
|
||||
import { Drawer, Spin, App, ConfigProvider, theme, Typography } from 'antd';
|
||||
import { MapContainer, useMapEvents } from 'react-leaflet';
|
||||
import MarkerClusterGroup from 'react-leaflet-cluster';
|
||||
import type { Map as LeafletMap } from 'leaflet';
|
||||
@ -64,7 +64,6 @@ export default function VolunteerMapPage() {
|
||||
const { user } = useAuthStore();
|
||||
const { settings } = useSettingsStore();
|
||||
const trackingStore = useTrackingStore();
|
||||
const screens = Grid.useBreakpoint();
|
||||
|
||||
const {
|
||||
mode, activeCutId, session,
|
||||
@ -101,7 +100,6 @@ export default function VolunteerMapPage() {
|
||||
const userRole = user?.role ?? 'TEMP';
|
||||
const isAdmin = MAP_ADMIN_ROLES.includes(userRole);
|
||||
const sessionActive = mode === 'session' && !!session;
|
||||
const isMobile = !screens.md;
|
||||
|
||||
// Footer nav height for positioning (no session bar, controls integrated into bottom panel)
|
||||
const FOOTER_HEIGHT = 56;
|
||||
@ -381,7 +379,7 @@ export default function VolunteerMapPage() {
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
const height = entries[0].contentRect.height;
|
||||
const height = entries[0]?.contentRect.height ?? 0;
|
||||
// Cap at 60vh to ensure map is always visible
|
||||
setMenuDrawerHeight(Math.min(height, window.innerHeight * 0.6));
|
||||
});
|
||||
|
||||
@ -1003,6 +1003,7 @@ export interface ServicesStatus {
|
||||
mailhog: { online: boolean; url: string };
|
||||
miniqr: { online: boolean; url: string };
|
||||
excalidraw: { online: boolean; url: string };
|
||||
homepage: { online: boolean; url: string };
|
||||
}
|
||||
|
||||
export interface ServicesConfig {
|
||||
@ -1034,6 +1035,9 @@ export interface ServicesConfig {
|
||||
// Alertmanager (alert routing)
|
||||
alertmanagerPort: number;
|
||||
alertmanagerSubdomain: string;
|
||||
// Homepage (service dashboard)
|
||||
homepagePort: number;
|
||||
homepageSubdomain: string;
|
||||
}
|
||||
|
||||
// --- Site Settings ---
|
||||
|
||||
149
admin/src/utils/videoCardHtml.ts
Normal file
149
admin/src/utils/videoCardHtml.ts
Normal file
@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Shared video card HTML generators for GrapesJS, MkDocs, and email templates.
|
||||
* Produces static HTML that works without JavaScript.
|
||||
*/
|
||||
|
||||
export interface VideoCardData {
|
||||
id: number | string;
|
||||
title: string;
|
||||
durationSeconds: number;
|
||||
quality: string;
|
||||
viewCount: number;
|
||||
thumbnailUrl: string;
|
||||
}
|
||||
|
||||
export interface VideoCardOptions {
|
||||
baseUrl?: string; // For absolute URL generation (email/MkDocs)
|
||||
}
|
||||
|
||||
export function formatDuration(seconds: number): string {
|
||||
if (!seconds || seconds <= 0) return '0:00';
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function formatViewCount(count: number): string {
|
||||
if (!count || count <= 0) return '0 views';
|
||||
if (count === 1) return '1 view';
|
||||
if (count < 1000) return `${count} views`;
|
||||
if (count < 1_000_000) return `${(count / 1000).toFixed(1).replace(/\.0$/, '')}K views`;
|
||||
return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, '')}M views`;
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function resolveUrl(path: string, baseUrl?: string): string {
|
||||
if (!baseUrl) return path;
|
||||
return `${baseUrl.replace(/\/$/, '')}${path}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* CSS-based video card HTML for GrapesJS landing pages and MkDocs.
|
||||
* Dark theme, inline styles, entire card is a clickable link.
|
||||
*/
|
||||
export function generateVideoCardHtml(video: VideoCardData, options: VideoCardOptions = {}): string {
|
||||
const { baseUrl } = options;
|
||||
const watchUrl = resolveUrl(`/gallery?expanded=${video.id}`, baseUrl);
|
||||
const thumbUrl = resolveUrl(video.thumbnailUrl, baseUrl);
|
||||
const title = escapeHtml(video.title || 'Untitled Video');
|
||||
const duration = formatDuration(video.durationSeconds);
|
||||
const views = formatViewCount(video.viewCount);
|
||||
const quality = escapeHtml(video.quality || '');
|
||||
|
||||
return `<div class="video-card-block" data-video-id="${video.id}" data-video-title="${escapeHtml(video.title)}" data-video-duration="${video.durationSeconds}" data-video-quality="${escapeHtml(video.quality || '')}" data-video-views="${video.viewCount}" style="max-width: 480px; margin: 0 auto;">
|
||||
<a href="${watchUrl}" style="display: block; text-decoration: none; color: inherit; border-radius: 12px; overflow: hidden; background: #1b2838; box-shadow: 0 4px 12px rgba(0,0,0,0.3);">
|
||||
<div style="position: relative; padding-bottom: 56.25%; background: #0d1b2a; overflow: hidden;">
|
||||
<img src="${thumbUrl}" alt="${title}" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover;" />
|
||||
${quality ? `<span style="position: absolute; top: 8px; left: 8px; background: #9d4edd; color: #fff; font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 4px;">${quality}</span>` : ''}
|
||||
<span style="position: absolute; bottom: 8px; right: 8px; background: rgba(0,0,0,0.8); color: #fff; font-size: 12px; font-weight: 500; padding: 2px 6px; border-radius: 4px;">${duration}</span>
|
||||
<div style="position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 56px; height: 56px; background: rgba(0,0,0,0.5); border-radius: 50%; display: flex; align-items: center; justify-content: center;">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="#fff" style="margin-left: 3px;"><polygon points="5,3 19,12 5,21"/></svg>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding: 12px 16px;">
|
||||
<div style="color: #fff; font-size: 15px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${title}</div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 6px;">
|
||||
<span style="color: #8899aa; font-size: 13px;">${views}</span>
|
||||
<span style="color: #9d4edd; font-size: 13px; font-weight: 500;">Watch →</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Table-based video card HTML for email templates.
|
||||
* Uses inline styles and table layout for maximum email client compatibility.
|
||||
*/
|
||||
export function generateVideoCardEmailHtml(video: VideoCardData, options: VideoCardOptions = {}): string {
|
||||
const { baseUrl } = options;
|
||||
const watchUrl = resolveUrl(`/gallery?expanded=${video.id}`, baseUrl);
|
||||
const thumbUrl = resolveUrl(video.thumbnailUrl, baseUrl);
|
||||
const title = escapeHtml(video.title || 'Untitled Video');
|
||||
const duration = formatDuration(video.durationSeconds);
|
||||
const views = formatViewCount(video.viewCount);
|
||||
const quality = escapeHtml(video.quality || '');
|
||||
|
||||
return `<table cellpadding="0" cellspacing="0" border="0" style="max-width: 480px; margin: 16px auto; border-radius: 8px; overflow: hidden; background-color: #1b2838;">
|
||||
<tr>
|
||||
<td style="padding: 0;">
|
||||
<a href="${watchUrl}" style="display: block; text-decoration: none;">
|
||||
<img src="${thumbUrl}" alt="${title}" width="480" style="width: 100%; max-width: 480px; height: auto; display: block;" />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding: 12px 16px;">
|
||||
<table cellpadding="0" cellspacing="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="color: #ffffff; font-size: 15px; font-weight: 600; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
|
||||
${title}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding-top: 6px;">
|
||||
<table cellpadding="0" cellspacing="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="color: #8899aa; font-size: 12px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
|
||||
${duration}${quality ? ` • ${quality}` : ''} • ${views}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding-top: 12px; text-align: center;">
|
||||
<a href="${watchUrl}" style="display: inline-block; padding: 10px 24px; background-color: #9d4edd; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 14px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
|
||||
▶ Watch Video
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain text fallback for email text part.
|
||||
*/
|
||||
export function generateVideoCardPlainText(video: VideoCardData, options: VideoCardOptions = {}): string {
|
||||
const { baseUrl } = options;
|
||||
const watchUrl = resolveUrl(`/gallery?expanded=${video.id}`, baseUrl);
|
||||
const duration = formatDuration(video.durationSeconds);
|
||||
const views = formatViewCount(video.viewCount);
|
||||
const quality = video.quality || '';
|
||||
const meta = [duration, quality, views].filter(Boolean).join(', ');
|
||||
|
||||
return `Watch "${video.title}" (${meta}) - ${watchUrl}`;
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@ -218,6 +218,27 @@ async function main() {
|
||||
showReactions: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'default-video-card',
|
||||
type: 'video-card',
|
||||
label: 'Video Card',
|
||||
category: 'Media',
|
||||
sortOrder: 8,
|
||||
schema: {
|
||||
videoId: { type: 'number', label: 'Video ID', required: true },
|
||||
title: { type: 'string', label: 'Title' },
|
||||
durationSeconds: { type: 'number', label: 'Duration (seconds)', default: 0 },
|
||||
quality: { type: 'string', label: 'Quality (e.g. 1080p)' },
|
||||
viewCount: { type: 'number', label: 'View Count', default: 0 },
|
||||
},
|
||||
defaults: {
|
||||
videoId: null,
|
||||
title: '',
|
||||
durationSeconds: 0,
|
||||
quality: '',
|
||||
viewCount: 0,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const block of defaultBlocks) {
|
||||
|
||||
@ -97,6 +97,10 @@ const envSchema = z.object({
|
||||
EXCALIDRAW_PORT: z.coerce.number().default(8090),
|
||||
EXCALIDRAW_EMBED_PORT: z.coerce.number().default(8886),
|
||||
|
||||
// Homepage (service dashboard)
|
||||
HOMEPAGE_URL: z.string().default('http://homepage-changemaker:3000'),
|
||||
HOMEPAGE_EMBED_PORT: z.coerce.number().default(8887),
|
||||
|
||||
// Pangolin (tunnel / reverse proxy)
|
||||
PANGOLIN_API_URL: z.string()
|
||||
.default('')
|
||||
|
||||
@ -66,9 +66,25 @@ process.on('uncaughtException', (error) => {
|
||||
// Start server
|
||||
const start = async () => {
|
||||
try {
|
||||
// CORS configuration
|
||||
// CORS configuration — allow admin app + MkDocs docs site
|
||||
const allowedOrigins = env.CORS_ORIGINS.split(',').map(o => o.trim());
|
||||
|
||||
// Auto-add MkDocs origins so video cards/players work in docs
|
||||
const mkdocsOrigin = `http://localhost:${env.MKDOCS_PORT || 4003}`;
|
||||
if (!allowedOrigins.includes(mkdocsOrigin)) {
|
||||
allowedOrigins.push(mkdocsOrigin);
|
||||
}
|
||||
// Also allow the docs subdomain in production (docs.domain.org)
|
||||
for (const origin of [...allowedOrigins]) {
|
||||
const match = origin.match(/^(https?:\/\/)app\./);
|
||||
if (match) {
|
||||
const docsOrigin = origin.replace(/^(https?:\/\/)app\./, '$1docs.');
|
||||
if (!allowedOrigins.includes(docsOrigin)) {
|
||||
allowedOrigins.push(docsOrigin);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await fastify.register(cors, {
|
||||
origin: (origin, cb) => {
|
||||
// Allow requests with no origin (mobile apps, curl, etc.)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { prisma } from '../../../config/database';
|
||||
import { requireAdminRole } from '../middleware/auth';
|
||||
|
||||
@ -26,10 +26,10 @@ interface ReorderBody {
|
||||
|
||||
export async function playlistsAdminRoutes(fastify: FastifyInstance) {
|
||||
// GET /api/media/playlists - All playlists (admin)
|
||||
fastify.get(
|
||||
fastify.get<{ Querystring: PaginationQuery }>(
|
||||
'/playlists',
|
||||
{ preHandler: requireAdminRole },
|
||||
async (request: FastifyRequest<{ Querystring: PaginationQuery }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const limit = Math.min(parseInt(request.query.limit || '25'), 100);
|
||||
const offset = parseInt(request.query.offset || '0');
|
||||
const search = request.query.search;
|
||||
@ -101,7 +101,7 @@ export async function playlistsAdminRoutes(fastify: FastifyInstance) {
|
||||
fastify.get(
|
||||
'/playlists/featured',
|
||||
{ preHandler: requireAdminRole },
|
||||
async (request: FastifyRequest, reply) => {
|
||||
async (request, reply) => {
|
||||
const featured = await prisma.featuredPlaylist.findMany({
|
||||
orderBy: { position: 'asc' },
|
||||
include: {
|
||||
@ -154,10 +154,10 @@ export async function playlistsAdminRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// POST /api/media/playlists/:id/feature - Feature a playlist
|
||||
fastify.post(
|
||||
fastify.post<{ Params: PlaylistParams }>(
|
||||
'/playlists/:id/feature',
|
||||
{ preHandler: requireAdminRole },
|
||||
async (request: FastifyRequest<{ Params: PlaylistParams }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const playlistId = parseInt(request.params.id);
|
||||
if (isNaN(playlistId)) {
|
||||
return reply.code(400).send({ message: 'Invalid playlist ID' });
|
||||
@ -202,10 +202,10 @@ export async function playlistsAdminRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// DELETE /api/media/playlists/:id/feature - Unfeature a playlist
|
||||
fastify.delete(
|
||||
fastify.delete<{ Params: PlaylistParams }>(
|
||||
'/playlists/:id/feature',
|
||||
{ preHandler: requireAdminRole },
|
||||
async (request: FastifyRequest<{ Params: PlaylistParams }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const playlistId = parseInt(request.params.id);
|
||||
if (isNaN(playlistId)) {
|
||||
return reply.code(400).send({ message: 'Invalid playlist ID' });
|
||||
@ -225,10 +225,10 @@ export async function playlistsAdminRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// PUT /api/media/playlists/featured/reorder - Reorder featured playlists
|
||||
fastify.put(
|
||||
fastify.put<{ Body: ReorderBody }>(
|
||||
'/playlists/featured/reorder',
|
||||
{ preHandler: requireAdminRole },
|
||||
async (request: FastifyRequest<{ Body: ReorderBody }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const { items } = request.body;
|
||||
if (!items || !Array.isArray(items)) {
|
||||
return reply.code(400).send({ message: 'items array is required' });
|
||||
@ -248,10 +248,10 @@ export async function playlistsAdminRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// PUT /api/media/playlists/:id - Admin update playlist metadata
|
||||
fastify.put(
|
||||
fastify.put<{ Params: PlaylistParams; Body: UpdatePlaylistBody }>(
|
||||
'/playlists/:id',
|
||||
{ preHandler: requireAdminRole },
|
||||
async (request: FastifyRequest<{ Params: PlaylistParams; Body: UpdatePlaylistBody }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const playlistId = parseInt(request.params.id);
|
||||
if (isNaN(playlistId)) {
|
||||
return reply.code(400).send({ message: 'Invalid playlist ID' });
|
||||
@ -303,10 +303,10 @@ export async function playlistsAdminRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// POST /api/media/playlists/:id/duplicate - Duplicate a playlist
|
||||
fastify.post(
|
||||
fastify.post<{ Params: PlaylistParams }>(
|
||||
'/playlists/:id/duplicate',
|
||||
{ preHandler: requireAdminRole },
|
||||
async (request: FastifyRequest<{ Params: PlaylistParams }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const playlistId = parseInt(request.params.id);
|
||||
if (isNaN(playlistId)) {
|
||||
return reply.code(400).send({ message: 'Invalid playlist ID' });
|
||||
@ -361,10 +361,10 @@ export async function playlistsAdminRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// DELETE /api/media/playlists/:id - Admin delete any playlist
|
||||
fastify.delete(
|
||||
fastify.delete<{ Params: PlaylistParams }>(
|
||||
'/playlists/:id',
|
||||
{ preHandler: requireAdminRole },
|
||||
async (request: FastifyRequest<{ Params: PlaylistParams }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const playlistId = parseInt(request.params.id);
|
||||
if (isNaN(playlistId)) {
|
||||
return reply.code(400).send({ message: 'Invalid playlist ID' });
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { prisma } from '../../../config/database';
|
||||
import { optionalAuth } from '../middleware/auth';
|
||||
import { logger } from '../../../utils/logger';
|
||||
@ -89,10 +89,10 @@ function formatPlaylistSummary(playlist: any, requestUserId?: string) {
|
||||
|
||||
export async function playlistsPublicRoutes(fastify: FastifyInstance) {
|
||||
// GET /api/playlists/featured - Get featured playlists
|
||||
fastify.get(
|
||||
fastify.get<{ Querystring: PaginationQuery }>(
|
||||
'/featured',
|
||||
{ preHandler: optionalAuth },
|
||||
async (request: FastifyRequest<{ Querystring: PaginationQuery }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const limit = Math.min(parseInt(request.query.limit || '12'), 50);
|
||||
const offset = parseInt(request.query.offset || '0');
|
||||
|
||||
@ -121,10 +121,10 @@ export async function playlistsPublicRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// GET /api/playlists/popular - Get popular public playlists
|
||||
fastify.get(
|
||||
fastify.get<{ Querystring: PaginationQuery }>(
|
||||
'/popular',
|
||||
{ preHandler: optionalAuth },
|
||||
async (request: FastifyRequest<{ Querystring: PaginationQuery }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const limit = Math.min(parseInt(request.query.limit || '12'), 100);
|
||||
const offset = parseInt(request.query.offset || '0');
|
||||
const search = request.query.search;
|
||||
@ -157,10 +157,10 @@ export async function playlistsPublicRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// GET /api/playlists/share/:token - Get playlist by share token
|
||||
fastify.get(
|
||||
fastify.get<{ Params: ShareParams }>(
|
||||
'/share/:token',
|
||||
{ preHandler: optionalAuth },
|
||||
async (request: FastifyRequest<{ Params: ShareParams }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const { token } = request.params;
|
||||
|
||||
const playlist = await prisma.playlist.findUnique({
|
||||
@ -228,13 +228,10 @@ export async function playlistsPublicRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// GET /api/playlists/:id - Get playlist detail
|
||||
fastify.get(
|
||||
fastify.get<{ Params: PlaylistParams; Querystring: PlaylistIdQuery }>(
|
||||
'/:id',
|
||||
{ preHandler: optionalAuth },
|
||||
async (
|
||||
request: FastifyRequest<{ Params: PlaylistParams; Querystring: PlaylistIdQuery }>,
|
||||
reply
|
||||
) => {
|
||||
async (request, reply) => {
|
||||
const playlistId = parseInt(request.params.id);
|
||||
if (isNaN(playlistId)) {
|
||||
return reply.code(400).send({ message: 'Invalid playlist ID' });
|
||||
@ -315,10 +312,10 @@ export async function playlistsPublicRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// POST /api/playlists/:id/view - Record playlist view
|
||||
fastify.post(
|
||||
fastify.post<{ Params: PlaylistParams }>(
|
||||
'/:id/view',
|
||||
{ preHandler: optionalAuth },
|
||||
async (request: FastifyRequest<{ Params: PlaylistParams }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const playlistId = parseInt(request.params.id);
|
||||
if (isNaN(playlistId)) {
|
||||
return reply.code(400).send({ message: 'Invalid playlist ID' });
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { prisma } from '../../../config/database';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { logger } from '../../../utils/logger';
|
||||
@ -80,7 +80,7 @@ export async function playlistsUserRoutes(fastify: FastifyInstance) {
|
||||
fastify.get(
|
||||
'/my',
|
||||
{ preHandler: authenticate },
|
||||
async (request: FastifyRequest, reply) => {
|
||||
async (request, reply) => {
|
||||
const userId = request.user!.id;
|
||||
|
||||
const playlists = await prisma.playlist.findMany({
|
||||
@ -139,10 +139,10 @@ export async function playlistsUserRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// POST /api/playlists/ - Create playlist
|
||||
fastify.post(
|
||||
fastify.post<{ Body: CreatePlaylistBody }>(
|
||||
'/',
|
||||
{ preHandler: authenticate },
|
||||
async (request: FastifyRequest<{ Body: CreatePlaylistBody }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const userId = request.user!.id;
|
||||
const { name, description, isPublic } = request.body;
|
||||
|
||||
@ -174,13 +174,10 @@ export async function playlistsUserRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// PUT /api/playlists/:id - Update playlist
|
||||
fastify.put(
|
||||
fastify.put<{ Params: PlaylistParams; Body: UpdatePlaylistBody }>(
|
||||
'/:id',
|
||||
{ preHandler: authenticate },
|
||||
async (
|
||||
request: FastifyRequest<{ Params: PlaylistParams; Body: UpdatePlaylistBody }>,
|
||||
reply
|
||||
) => {
|
||||
async (request, reply) => {
|
||||
const playlistId = parseInt(request.params.id);
|
||||
if (isNaN(playlistId)) {
|
||||
return reply.code(400).send({ message: 'Invalid playlist ID' });
|
||||
@ -227,10 +224,10 @@ export async function playlistsUserRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// DELETE /api/playlists/:id - Delete playlist
|
||||
fastify.delete(
|
||||
fastify.delete<{ Params: PlaylistParams }>(
|
||||
'/:id',
|
||||
{ preHandler: authenticate },
|
||||
async (request: FastifyRequest<{ Params: PlaylistParams }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const playlistId = parseInt(request.params.id);
|
||||
if (isNaN(playlistId)) {
|
||||
return reply.code(400).send({ message: 'Invalid playlist ID' });
|
||||
@ -250,13 +247,10 @@ export async function playlistsUserRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// POST /api/playlists/:id/videos - Add video to playlist
|
||||
fastify.post(
|
||||
fastify.post<{ Params: PlaylistParams; Body: AddVideoBody }>(
|
||||
'/:id/videos',
|
||||
{ preHandler: authenticate },
|
||||
async (
|
||||
request: FastifyRequest<{ Params: PlaylistParams; Body: AddVideoBody }>,
|
||||
reply
|
||||
) => {
|
||||
async (request, reply) => {
|
||||
const playlistId = parseInt(request.params.id);
|
||||
if (isNaN(playlistId)) {
|
||||
return reply.code(400).send({ message: 'Invalid playlist ID' });
|
||||
@ -316,10 +310,10 @@ export async function playlistsUserRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// DELETE /api/playlists/:id/videos/:mediaId - Remove video from playlist
|
||||
fastify.delete(
|
||||
fastify.delete<{ Params: VideoParams }>(
|
||||
'/:id/videos/:mediaId',
|
||||
{ preHandler: authenticate },
|
||||
async (request: FastifyRequest<{ Params: VideoParams }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const playlistId = parseInt(request.params.id);
|
||||
const mediaId = parseInt(request.params.mediaId);
|
||||
|
||||
@ -350,13 +344,10 @@ export async function playlistsUserRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// PUT /api/playlists/:id/videos/reorder - Reorder videos
|
||||
fastify.put(
|
||||
fastify.put<{ Params: PlaylistParams; Body: ReorderBody }>(
|
||||
'/:id/videos/reorder',
|
||||
{ preHandler: authenticate },
|
||||
async (
|
||||
request: FastifyRequest<{ Params: PlaylistParams; Body: ReorderBody }>,
|
||||
reply
|
||||
) => {
|
||||
async (request, reply) => {
|
||||
const playlistId = parseInt(request.params.id);
|
||||
if (isNaN(playlistId)) {
|
||||
return reply.code(400).send({ message: 'Invalid playlist ID' });
|
||||
@ -394,10 +385,10 @@ export async function playlistsUserRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// POST /api/playlists/:id/share - Generate share token
|
||||
fastify.post(
|
||||
fastify.post<{ Params: PlaylistParams }>(
|
||||
'/:id/share',
|
||||
{ preHandler: authenticate },
|
||||
async (request: FastifyRequest<{ Params: PlaylistParams }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const playlistId = parseInt(request.params.id);
|
||||
if (isNaN(playlistId)) {
|
||||
return reply.code(400).send({ message: 'Invalid playlist ID' });
|
||||
@ -422,10 +413,10 @@ export async function playlistsUserRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// DELETE /api/playlists/:id/share - Revoke share token
|
||||
fastify.delete(
|
||||
fastify.delete<{ Params: PlaylistParams }>(
|
||||
'/:id/share',
|
||||
{ preHandler: authenticate },
|
||||
async (request: FastifyRequest<{ Params: PlaylistParams }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const playlistId = parseInt(request.params.id);
|
||||
if (isNaN(playlistId)) {
|
||||
return reply.code(400).send({ message: 'Invalid playlist ID' });
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { createReadStream, stat } from 'fs';
|
||||
import { access } from 'fs/promises';
|
||||
import { join } from 'path';
|
||||
@ -22,12 +22,12 @@ interface PublicVideosQuery {
|
||||
|
||||
export async function publicRoutes(fastify: FastifyInstance) {
|
||||
// GET /api/public - List published videos (unauthenticated)
|
||||
fastify.get(
|
||||
fastify.get<{ Querystring: PublicVideosQuery }>(
|
||||
'/public',
|
||||
{
|
||||
preHandler: optionalAuth,
|
||||
},
|
||||
async (request: FastifyRequest<{ Querystring: PublicVideosQuery }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const limit = parseInt(request.query.limit || '24');
|
||||
const offset = parseInt(request.query.offset || '0');
|
||||
const search = request.query.search;
|
||||
@ -112,12 +112,12 @@ export async function publicRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// GET /api/public/:id - Get single published video (unauthenticated)
|
||||
fastify.get(
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
'/public/:id',
|
||||
{
|
||||
preHandler: optionalAuth,
|
||||
},
|
||||
async (request: FastifyRequest<{ Params: { id: string } }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const videoId = parseInt(request.params.id);
|
||||
|
||||
const video = await prisma.video.findFirst({
|
||||
@ -181,12 +181,12 @@ export async function publicRoutes(fastify: FastifyInstance) {
|
||||
});
|
||||
|
||||
// GET /api/public/:id/thumbnail - Get video thumbnail (unauthenticated)
|
||||
fastify.get(
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
'/public/:id/thumbnail',
|
||||
{
|
||||
preHandler: optionalAuth,
|
||||
},
|
||||
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
|
||||
async (request, reply) => {
|
||||
const videoId = parseInt(request.params.id);
|
||||
|
||||
const video = await prisma.video.findFirst({
|
||||
@ -245,12 +245,12 @@ export async function publicRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// GET /api/public/:id/stream - Stream video file (unauthenticated)
|
||||
fastify.get(
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
'/public/:id/stream',
|
||||
{
|
||||
preHandler: optionalAuth,
|
||||
},
|
||||
async (request: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => {
|
||||
async (request, reply) => {
|
||||
const videoId = parseInt(request.params.id);
|
||||
|
||||
const video = await prisma.video.findFirst({
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { prisma } from '../../../config/database';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
import { broadcastReactionToVideo } from './chat-stream.routes.js';
|
||||
@ -43,12 +43,12 @@ interface GetReactionsQuery {
|
||||
|
||||
export async function reactionsRoutes(fastify: FastifyInstance) {
|
||||
// Add reaction (authenticated users only)
|
||||
fastify.post(
|
||||
fastify.post<{ Body: AddReactionBody }>(
|
||||
'/',
|
||||
{
|
||||
preHandler: authenticate,
|
||||
},
|
||||
async (request: FastifyRequest<{ Body: AddReactionBody }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const { mediaId, reactionType, videoTimestamp } = request.body;
|
||||
const userId = request.user!.id;
|
||||
|
||||
@ -130,9 +130,9 @@ export async function reactionsRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// Get reactions
|
||||
fastify.get(
|
||||
fastify.get<{ Querystring: GetReactionsQuery }>(
|
||||
'/',
|
||||
async (request: FastifyRequest<{ Querystring: GetReactionsQuery }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const { mediaId, userId, limit = '50' } = request.query;
|
||||
|
||||
const where: any = {};
|
||||
@ -169,7 +169,7 @@ export async function reactionsRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// Get reaction config (returns available reactions)
|
||||
fastify.get('/config', async (request: FastifyRequest, reply) => {
|
||||
fastify.get('/config', async (request, reply) => {
|
||||
return {
|
||||
reactions: Object.entries(REACTION_EMOJIS).map(([type, emoji]) => ({
|
||||
type,
|
||||
@ -180,15 +180,12 @@ export async function reactionsRoutes(fastify: FastifyInstance) {
|
||||
});
|
||||
|
||||
// Get reactions for chat timeline (non-aggregated, for display in chat)
|
||||
fastify.get(
|
||||
fastify.get<{
|
||||
Params: { mediaId: string };
|
||||
Querystring: { limit?: string };
|
||||
}>(
|
||||
'/:mediaId/chat',
|
||||
async (
|
||||
request: FastifyRequest<{
|
||||
Params: { mediaId: string };
|
||||
Querystring: { limit?: string };
|
||||
}>,
|
||||
reply
|
||||
) => {
|
||||
async (request, reply) => {
|
||||
const mediaId = parseInt(request.params.mediaId, 10);
|
||||
const limit = parseInt(request.query.limit || '500', 10);
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { prisma } from '../../../config/database';
|
||||
import { optionalAuth, requireAdminRole } from '../middleware/auth';
|
||||
import { logger } from '../../../utils/logger';
|
||||
@ -15,12 +15,12 @@ export async function shortsRoutes(fastify: FastifyInstance) {
|
||||
* GET /api/shorts - Public shorts feed
|
||||
* Returns published short videos (<=60s) for the TikTok-style feed
|
||||
*/
|
||||
fastify.get(
|
||||
fastify.get<{ Querystring: ShortsQuery }>(
|
||||
'/shorts',
|
||||
{
|
||||
preHandler: optionalAuth,
|
||||
},
|
||||
async (request: FastifyRequest<{ Querystring: ShortsQuery }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const limit = Math.min(parseInt(request.query.limit || '20'), 50);
|
||||
const offset = parseInt(request.query.offset || '0');
|
||||
const sort = request.query.sort || 'recent';
|
||||
|
||||
@ -43,17 +43,6 @@ async function uploadVideo(request: FastifyRequest, reply: FastifyReply) {
|
||||
});
|
||||
}
|
||||
|
||||
// Extract metadata fields from form data
|
||||
const metadataFields = data.fields as Record<string, { value: string }>;
|
||||
const metadata = {
|
||||
title: metadataFields.title?.value,
|
||||
producer: metadataFields.producer?.value,
|
||||
creator: metadataFields.creator?.value,
|
||||
};
|
||||
|
||||
// Validate metadata
|
||||
const validatedMetadata = UploadMetadataSchema.parse(metadata);
|
||||
|
||||
// Generate unique filename
|
||||
const filename = `${randomUUID()}${ext}`;
|
||||
const inboxDir = '/media/local/inbox';
|
||||
@ -64,10 +53,22 @@ async function uploadVideo(request: FastifyRequest, reply: FastifyReply) {
|
||||
const filePath = join(inboxDir, filename);
|
||||
tempFilePath = filePath;
|
||||
|
||||
// Stream file to disk
|
||||
// Stream file to disk (must consume file stream BEFORE reading metadata fields,
|
||||
// because Fastify multipart/busboy only makes fields available after the file stream ends)
|
||||
logger.info(`Uploading video to ${filePath}`);
|
||||
await pipeline(data.file, createWriteStream(filePath));
|
||||
|
||||
// Extract metadata fields from form data (now available after file stream consumed)
|
||||
const metadataFields = data.fields as Record<string, { value: string }>;
|
||||
const metadata = {
|
||||
title: metadataFields.title?.value,
|
||||
producer: metadataFields.producer?.value,
|
||||
creator: metadataFields.creator?.value,
|
||||
};
|
||||
|
||||
// Validate metadata
|
||||
const validatedMetadata = UploadMetadataSchema.parse(metadata);
|
||||
|
||||
// Validate video file
|
||||
logger.info(`Validating video file: ${filePath}`);
|
||||
const isValid = await validateVideoFile(filePath);
|
||||
@ -96,6 +97,7 @@ async function uploadVideo(request: FastifyRequest, reply: FastifyReply) {
|
||||
fileSize: videoMetadata.fileSize,
|
||||
directoryType: 'inbox',
|
||||
isValid: true,
|
||||
isShort: videoMetadata.durationSeconds != null && videoMetadata.durationSeconds <= 60,
|
||||
producer: validatedMetadata.producer || null,
|
||||
creator: validatedMetadata.creator || null,
|
||||
},
|
||||
@ -220,6 +222,7 @@ async function uploadBatch(request: FastifyRequest, reply: FastifyReply) {
|
||||
fileSize: videoMetadata.fileSize,
|
||||
directoryType: 'inbox',
|
||||
isValid: true,
|
||||
isShort: videoMetadata.durationSeconds != null && videoMetadata.durationSeconds <= 60,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { FastifyInstance, FastifyRequest } from 'fastify';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { prisma } from '../../../config/database';
|
||||
import { authenticate } from '../middleware/auth';
|
||||
@ -40,7 +40,7 @@ export async function userProfileRoutes(fastify: FastifyInstance) {
|
||||
fastify.get(
|
||||
'/me/stats',
|
||||
{ preHandler: [authenticate] },
|
||||
async (request: FastifyRequest, reply) => {
|
||||
async (request, reply) => {
|
||||
const userId = request.user!.id;
|
||||
|
||||
// Upsert UserStats (create with defaults if missing)
|
||||
@ -80,13 +80,10 @@ export async function userProfileRoutes(fastify: FastifyInstance) {
|
||||
* GET /me/watch-history
|
||||
* Paginated recent VideoView records with video info
|
||||
*/
|
||||
fastify.get(
|
||||
fastify.get<{ Querystring: WatchHistoryQuery }>(
|
||||
'/me/watch-history',
|
||||
{ preHandler: [authenticate] },
|
||||
async (
|
||||
request: FastifyRequest<{ Querystring: WatchHistoryQuery }>,
|
||||
reply
|
||||
) => {
|
||||
async (request, reply) => {
|
||||
const userId = request.user!.id;
|
||||
const limit = Math.min(parseInt(request.query.limit || '20', 10), 50);
|
||||
const offset = parseInt(request.query.offset || '0', 10);
|
||||
@ -127,7 +124,7 @@ export async function userProfileRoutes(fastify: FastifyInstance) {
|
||||
fastify.post(
|
||||
'/me/stats/recalculate',
|
||||
{ preHandler: [authenticate] },
|
||||
async (request: FastifyRequest, reply) => {
|
||||
async (request, reply) => {
|
||||
const userId = request.user!.id;
|
||||
|
||||
// Aggregate from raw tables
|
||||
@ -258,7 +255,7 @@ export async function userProfileRoutes(fastify: FastifyInstance) {
|
||||
fastify.get(
|
||||
'/me/settings',
|
||||
{ preHandler: [authenticate] },
|
||||
async (request: FastifyRequest, reply) => {
|
||||
async (request, reply) => {
|
||||
const userId = request.user!.id;
|
||||
|
||||
const [privacy, user] = await Promise.all([
|
||||
@ -281,13 +278,10 @@ export async function userProfileRoutes(fastify: FastifyInstance) {
|
||||
* PUT /me/settings
|
||||
* Update PrivacySettings boolean toggles
|
||||
*/
|
||||
fastify.put(
|
||||
fastify.put<{ Body: UpdateSettingsBody }>(
|
||||
'/me/settings',
|
||||
{ preHandler: [authenticate] },
|
||||
async (
|
||||
request: FastifyRequest<{ Body: UpdateSettingsBody }>,
|
||||
reply
|
||||
) => {
|
||||
async (request, reply) => {
|
||||
const userId = request.user!.id;
|
||||
const body = request.body;
|
||||
|
||||
@ -324,13 +318,10 @@ export async function userProfileRoutes(fastify: FastifyInstance) {
|
||||
* PUT /me/profile
|
||||
* Update user name (email is read-only)
|
||||
*/
|
||||
fastify.put(
|
||||
fastify.put<{ Body: UpdateProfileBody }>(
|
||||
'/me/profile',
|
||||
{ preHandler: [authenticate] },
|
||||
async (
|
||||
request: FastifyRequest<{ Body: UpdateProfileBody }>,
|
||||
reply
|
||||
) => {
|
||||
async (request, reply) => {
|
||||
const userId = request.user!.id;
|
||||
const { name } = request.body;
|
||||
|
||||
@ -352,13 +343,10 @@ export async function userProfileRoutes(fastify: FastifyInstance) {
|
||||
* PUT /me/password
|
||||
* Change password (requires current password verification)
|
||||
*/
|
||||
fastify.put(
|
||||
fastify.put<{ Body: ChangePasswordBody }>(
|
||||
'/me/password',
|
||||
{ preHandler: [authenticate] },
|
||||
async (
|
||||
request: FastifyRequest<{ Body: ChangePasswordBody }>,
|
||||
reply
|
||||
) => {
|
||||
async (request, reply) => {
|
||||
const userId = request.user!.id;
|
||||
const { currentPassword, newPassword } = request.body;
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { prisma } from '../../../config/database';
|
||||
import { requireAdminRole } from '../middleware/auth';
|
||||
import { videoAnalyticsService } from '../services/video-analytics.service';
|
||||
@ -25,12 +25,12 @@ export async function videoActionsRoutes(fastify: FastifyInstance) {
|
||||
* PATCH /videos/:id
|
||||
* Update video metadata
|
||||
*/
|
||||
fastify.patch(
|
||||
fastify.patch<{ Params: { id: string } }>(
|
||||
'/:id',
|
||||
{
|
||||
preHandler: requireAdminRole,
|
||||
},
|
||||
async (request: FastifyRequest<{ Params: { id: string } }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const videoId = parseInt(request.params.id);
|
||||
const parseResult = UpdateVideoSchema.safeParse(request.body);
|
||||
|
||||
@ -82,12 +82,12 @@ export async function videoActionsRoutes(fastify: FastifyInstance) {
|
||||
* POST /videos/:id/duplicate
|
||||
* Duplicate a video with a new title
|
||||
*/
|
||||
fastify.post(
|
||||
fastify.post<{ Params: { id: string }; Body: { title?: string } }>(
|
||||
'/:id/duplicate',
|
||||
{
|
||||
preHandler: requireAdminRole,
|
||||
},
|
||||
async (request: FastifyRequest<{ Params: { id: string }; Body: { title?: string } }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const videoId = parseInt(request.params.id);
|
||||
const { title } = request.body || {};
|
||||
|
||||
@ -119,7 +119,7 @@ export async function videoActionsRoutes(fastify: FastifyInstance) {
|
||||
width: originalVideo.width,
|
||||
height: originalVideo.height,
|
||||
thumbnailPath: originalVideo.thumbnailPath,
|
||||
tags: originalVideo.tags,
|
||||
tags: originalVideo.tags as any,
|
||||
directoryType: originalVideo.directoryType,
|
||||
category: originalVideo.category,
|
||||
uploaderId: originalVideo.uploaderId,
|
||||
@ -147,25 +147,22 @@ export async function videoActionsRoutes(fastify: FastifyInstance) {
|
||||
* Replace video file while keeping metadata and URL
|
||||
* Note: This endpoint accepts a new file path - actual file upload should go through upload routes
|
||||
*/
|
||||
fastify.post(
|
||||
fastify.post<{
|
||||
Params: { id: string };
|
||||
Body: {
|
||||
newPath: string;
|
||||
newFilename: string;
|
||||
durationSeconds?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
fileSize?: number;
|
||||
};
|
||||
}>(
|
||||
'/:id/replace',
|
||||
{
|
||||
preHandler: requireAdminRole,
|
||||
},
|
||||
async (
|
||||
request: FastifyRequest<{
|
||||
Params: { id: string };
|
||||
Body: {
|
||||
newPath: string;
|
||||
newFilename: string;
|
||||
durationSeconds?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
fileSize?: number;
|
||||
};
|
||||
}>,
|
||||
reply
|
||||
) => {
|
||||
async (request, reply) => {
|
||||
const videoId = parseInt(request.params.id);
|
||||
const { newPath, newFilename, durationSeconds, width, height, fileSize } = request.body;
|
||||
|
||||
@ -216,18 +213,15 @@ export async function videoActionsRoutes(fastify: FastifyInstance) {
|
||||
* GET /videos/:id/analytics
|
||||
* Get detailed analytics for a video
|
||||
*/
|
||||
fastify.get(
|
||||
fastify.get<{
|
||||
Params: { id: string };
|
||||
Querystring: { startDate?: string; endDate?: string };
|
||||
}>(
|
||||
'/:id/analytics',
|
||||
{
|
||||
preHandler: requireAdminRole,
|
||||
},
|
||||
async (
|
||||
request: FastifyRequest<{
|
||||
Params: { id: string };
|
||||
Querystring: { startDate?: string; endDate?: string };
|
||||
}>,
|
||||
reply
|
||||
) => {
|
||||
async (request, reply) => {
|
||||
const videoId = parseInt(request.params.id);
|
||||
const { startDate, endDate } = request.query;
|
||||
|
||||
@ -253,12 +247,12 @@ export async function videoActionsRoutes(fastify: FastifyInstance) {
|
||||
* POST /videos/:id/reset-analytics
|
||||
* Reset all analytics for a video
|
||||
*/
|
||||
fastify.post(
|
||||
fastify.post<{ Params: { id: string } }>(
|
||||
'/:id/reset-analytics',
|
||||
{
|
||||
preHandler: requireAdminRole,
|
||||
},
|
||||
async (request: FastifyRequest<{ Params: { id: string } }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const videoId = parseInt(request.params.id);
|
||||
|
||||
try {
|
||||
@ -279,12 +273,12 @@ export async function videoActionsRoutes(fastify: FastifyInstance) {
|
||||
* GET /videos/:id/preview-link
|
||||
* Generate a temporary preview link with expiring JWT token
|
||||
*/
|
||||
fastify.get(
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
'/:id/preview-link',
|
||||
{
|
||||
preHandler: requireAdminRole,
|
||||
},
|
||||
async (request: FastifyRequest<{ Params: { id: string } }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const videoId = parseInt(request.params.id);
|
||||
|
||||
try {
|
||||
@ -307,7 +301,7 @@ export async function videoActionsRoutes(fastify: FastifyInstance) {
|
||||
{ expiresIn: `${expiryHours}h` }
|
||||
);
|
||||
|
||||
const previewUrl = `${env.MEDIA_API_URL}/api/videos/${videoId}/preview?token=${token}`;
|
||||
const previewUrl = `${env.MEDIA_API_PUBLIC_URL}/api/videos/${videoId}/preview?token=${token}`;
|
||||
|
||||
logger.info(`Generated preview link for video ${videoId}`, { expiresInHours: expiryHours });
|
||||
|
||||
@ -327,17 +321,14 @@ export async function videoActionsRoutes(fastify: FastifyInstance) {
|
||||
* GET /videos/analytics/top
|
||||
* Get top performing videos
|
||||
*/
|
||||
fastify.get(
|
||||
fastify.get<{
|
||||
Querystring: { metric?: 'views' | 'watchTime'; limit?: string };
|
||||
}>(
|
||||
'/analytics/top',
|
||||
{
|
||||
preHandler: requireAdminRole,
|
||||
},
|
||||
async (
|
||||
request: FastifyRequest<{
|
||||
Querystring: { metric?: 'views' | 'watchTime'; limit?: string };
|
||||
}>,
|
||||
reply
|
||||
) => {
|
||||
async (request, reply) => {
|
||||
const metric = request.query.metric || 'views';
|
||||
const limit = parseInt(request.query.limit || '10');
|
||||
|
||||
@ -364,7 +355,7 @@ export async function videoActionsRoutes(fastify: FastifyInstance) {
|
||||
{
|
||||
preHandler: requireAdminRole,
|
||||
},
|
||||
async (request: FastifyRequest, reply) => {
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const [totalVideos, totalViews, totalWatchTime, avgCompletionRate] = await Promise.all([
|
||||
prisma.video.count(),
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { prisma } from '../../../config/database';
|
||||
import { requireAdminRole } from '../middleware/auth';
|
||||
import { videoScheduleQueueService } from '../../../services/video-schedule-queue.service';
|
||||
@ -21,18 +21,15 @@ export async function videoScheduleRoutes(fastify: FastifyInstance) {
|
||||
* POST /videos/:id/schedule-publish
|
||||
* Schedule a video to be published at a specific time
|
||||
*/
|
||||
fastify.post(
|
||||
fastify.post<{
|
||||
Params: { id: string };
|
||||
Body: { publishAt: string; timezone?: string };
|
||||
}>(
|
||||
'/:id/schedule-publish',
|
||||
{
|
||||
preHandler: requireAdminRole,
|
||||
},
|
||||
async (
|
||||
request: FastifyRequest<{
|
||||
Params: { id: string };
|
||||
Body: { publishAt: string; timezone?: string };
|
||||
}>,
|
||||
reply
|
||||
) => {
|
||||
async (request, reply) => {
|
||||
const videoId = parseInt(request.params.id);
|
||||
const { publishAt, timezone } = request.body;
|
||||
|
||||
@ -95,18 +92,15 @@ export async function videoScheduleRoutes(fastify: FastifyInstance) {
|
||||
* POST /videos/:id/schedule-unpublish
|
||||
* Schedule a video to be unpublished at a specific time
|
||||
*/
|
||||
fastify.post(
|
||||
fastify.post<{
|
||||
Params: { id: string };
|
||||
Body: { unpublishAt: string; timezone?: string };
|
||||
}>(
|
||||
'/:id/schedule-unpublish',
|
||||
{
|
||||
preHandler: requireAdminRole,
|
||||
},
|
||||
async (
|
||||
request: FastifyRequest<{
|
||||
Params: { id: string };
|
||||
Body: { unpublishAt: string; timezone?: string };
|
||||
}>,
|
||||
reply
|
||||
) => {
|
||||
async (request, reply) => {
|
||||
const videoId = parseInt(request.params.id);
|
||||
const { unpublishAt, timezone } = request.body;
|
||||
|
||||
@ -169,17 +163,14 @@ export async function videoScheduleRoutes(fastify: FastifyInstance) {
|
||||
* DELETE /videos/:id/schedule/:action
|
||||
* Cancel a scheduled publish or unpublish
|
||||
*/
|
||||
fastify.delete(
|
||||
fastify.delete<{
|
||||
Params: { id: string; action: string };
|
||||
}>(
|
||||
'/:id/schedule/:action',
|
||||
{
|
||||
preHandler: requireAdminRole,
|
||||
},
|
||||
async (
|
||||
request: FastifyRequest<{
|
||||
Params: { id: string; action: string };
|
||||
}>,
|
||||
reply
|
||||
) => {
|
||||
async (request, reply) => {
|
||||
const videoId = parseInt(request.params.id);
|
||||
const action = request.params.action as 'publish' | 'unpublish';
|
||||
|
||||
@ -209,17 +200,14 @@ export async function videoScheduleRoutes(fastify: FastifyInstance) {
|
||||
* GET /videos/schedules/upcoming
|
||||
* Get all upcoming scheduled publish/unpublish operations
|
||||
*/
|
||||
fastify.get(
|
||||
fastify.get<{
|
||||
Querystring: { limit?: string };
|
||||
}>(
|
||||
'/schedules/upcoming',
|
||||
{
|
||||
preHandler: requireAdminRole,
|
||||
},
|
||||
async (
|
||||
request: FastifyRequest<{
|
||||
Querystring: { limit?: string };
|
||||
}>,
|
||||
reply
|
||||
) => {
|
||||
async (request, reply) => {
|
||||
const limit = parseInt(request.query.limit || '50');
|
||||
|
||||
try {
|
||||
@ -240,18 +228,15 @@ export async function videoScheduleRoutes(fastify: FastifyInstance) {
|
||||
* GET /videos/:id/schedule-history
|
||||
* Get schedule history for a specific video
|
||||
*/
|
||||
fastify.get(
|
||||
fastify.get<{
|
||||
Params: { id: string };
|
||||
Querystring: { limit?: string };
|
||||
}>(
|
||||
'/:id/schedule-history',
|
||||
{
|
||||
preHandler: requireAdminRole,
|
||||
},
|
||||
async (
|
||||
request: FastifyRequest<{
|
||||
Params: { id: string };
|
||||
Querystring: { limit?: string };
|
||||
}>,
|
||||
reply
|
||||
) => {
|
||||
async (request, reply) => {
|
||||
const videoId = parseInt(request.params.id);
|
||||
const limit = parseInt(request.query.limit || '10');
|
||||
|
||||
@ -278,7 +263,7 @@ export async function videoScheduleRoutes(fastify: FastifyInstance) {
|
||||
{
|
||||
preHandler: requireAdminRole,
|
||||
},
|
||||
async (request: FastifyRequest, reply) => {
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const stats = await videoScheduleQueueService.getStats();
|
||||
|
||||
@ -299,7 +284,7 @@ export async function videoScheduleRoutes(fastify: FastifyInstance) {
|
||||
{
|
||||
preHandler: requireAdminRole,
|
||||
},
|
||||
async (request: FastifyRequest, reply) => {
|
||||
async (request, reply) => {
|
||||
try {
|
||||
await videoScheduleQueueService.pause();
|
||||
|
||||
@ -323,7 +308,7 @@ export async function videoScheduleRoutes(fastify: FastifyInstance) {
|
||||
{
|
||||
preHandler: requireAdminRole,
|
||||
},
|
||||
async (request: FastifyRequest, reply) => {
|
||||
async (request, reply) => {
|
||||
try {
|
||||
await videoScheduleQueueService.resume();
|
||||
|
||||
@ -347,7 +332,7 @@ export async function videoScheduleRoutes(fastify: FastifyInstance) {
|
||||
{
|
||||
preHandler: requireAdminRole,
|
||||
},
|
||||
async (request: FastifyRequest, reply) => {
|
||||
async (request, reply) => {
|
||||
try {
|
||||
const cleaned = await videoScheduleQueueService.cleanup();
|
||||
|
||||
|
||||
@ -1,15 +1,9 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { optionalAuth } from '../middleware/auth';
|
||||
import { videoAnalyticsService } from '../services/video-analytics.service';
|
||||
import { logger } from '../../../utils/logger';
|
||||
import { z } from 'zod';
|
||||
|
||||
// Rate limiting: 100 requests per minute per IP for tracking
|
||||
const trackingRateLimit = {
|
||||
max: 100,
|
||||
timeWindow: '1 minute',
|
||||
};
|
||||
|
||||
// Validation schemas
|
||||
const recordViewSchema = z.object({
|
||||
videoId: z.number(),
|
||||
@ -34,20 +28,14 @@ export async function videoTrackingRoutes(fastify: FastifyInstance) {
|
||||
* Record a new video view (called when video starts loading)
|
||||
* Public endpoint - no auth required, but optionally uses auth if available
|
||||
*/
|
||||
fastify.post(
|
||||
fastify.post<{
|
||||
Body: { videoId: number; referer?: string };
|
||||
}>(
|
||||
'/view',
|
||||
{
|
||||
preHandler: optionalAuth,
|
||||
config: {
|
||||
rateLimit: trackingRateLimit,
|
||||
},
|
||||
},
|
||||
async (
|
||||
request: FastifyRequest<{
|
||||
Body: { videoId: number; referer?: string };
|
||||
}>,
|
||||
reply
|
||||
) => {
|
||||
async (request, reply) => {
|
||||
const { videoId, referer } = request.body;
|
||||
|
||||
// Validate input
|
||||
@ -94,24 +82,16 @@ export async function videoTrackingRoutes(fastify: FastifyInstance) {
|
||||
* Record a video event (play, pause, seek, complete)
|
||||
* Public endpoint - no auth required
|
||||
*/
|
||||
fastify.post(
|
||||
fastify.post<{
|
||||
Body: {
|
||||
videoId: number;
|
||||
viewId?: number;
|
||||
eventType: 'play' | 'pause' | 'seek' | 'complete';
|
||||
timestamp: number;
|
||||
};
|
||||
}>(
|
||||
'/event',
|
||||
{
|
||||
config: {
|
||||
rateLimit: trackingRateLimit,
|
||||
},
|
||||
},
|
||||
async (
|
||||
request: FastifyRequest<{
|
||||
Body: {
|
||||
videoId: number;
|
||||
viewId?: number;
|
||||
eventType: 'play' | 'pause' | 'seek' | 'complete';
|
||||
timestamp: number;
|
||||
};
|
||||
}>,
|
||||
reply
|
||||
) => {
|
||||
async (request, reply) => {
|
||||
const { videoId, viewId, eventType, timestamp } = request.body;
|
||||
|
||||
// Validate input
|
||||
@ -147,25 +127,14 @@ export async function videoTrackingRoutes(fastify: FastifyInstance) {
|
||||
* Update watch time for a view (called every 10 seconds during playback)
|
||||
* Public endpoint - no auth required
|
||||
*/
|
||||
fastify.post(
|
||||
fastify.post<{
|
||||
Body: {
|
||||
viewId: number;
|
||||
watchTimeSeconds: number;
|
||||
};
|
||||
}>(
|
||||
'/heartbeat',
|
||||
{
|
||||
config: {
|
||||
rateLimit: {
|
||||
max: 200, // Higher limit for heartbeats (every 10s)
|
||||
timeWindow: '1 minute',
|
||||
},
|
||||
},
|
||||
},
|
||||
async (
|
||||
request: FastifyRequest<{
|
||||
Body: {
|
||||
viewId: number;
|
||||
watchTimeSeconds: number;
|
||||
};
|
||||
}>,
|
||||
reply
|
||||
) => {
|
||||
async (request, reply) => {
|
||||
const { viewId, watchTimeSeconds } = request.body;
|
||||
|
||||
// Validate input
|
||||
@ -196,27 +165,16 @@ export async function videoTrackingRoutes(fastify: FastifyInstance) {
|
||||
* Batch record multiple events (useful for reducing requests)
|
||||
* Public endpoint - no auth required
|
||||
*/
|
||||
fastify.post(
|
||||
fastify.post<{
|
||||
Body: {
|
||||
events: Array<{
|
||||
type: 'view' | 'event' | 'heartbeat';
|
||||
data: any;
|
||||
}>;
|
||||
};
|
||||
}>(
|
||||
'/batch',
|
||||
{
|
||||
config: {
|
||||
rateLimit: {
|
||||
max: 50,
|
||||
timeWindow: '1 minute',
|
||||
},
|
||||
},
|
||||
},
|
||||
async (
|
||||
request: FastifyRequest<{
|
||||
Body: {
|
||||
events: Array<{
|
||||
type: 'view' | 'event' | 'heartbeat';
|
||||
data: any;
|
||||
}>;
|
||||
};
|
||||
}>,
|
||||
reply
|
||||
) => {
|
||||
async (request, reply) => {
|
||||
const { events } = request.body;
|
||||
|
||||
if (!Array.isArray(events) || events.length === 0) {
|
||||
@ -266,7 +224,7 @@ export async function videoTrackingRoutes(fastify: FastifyInstance) {
|
||||
* GET /track/health
|
||||
* Health check for tracking endpoints
|
||||
*/
|
||||
fastify.get('/health', async (request: FastifyRequest, reply) => {
|
||||
fastify.get('/health', async (request, reply) => {
|
||||
return {
|
||||
status: 'ok',
|
||||
service: 'video-tracking',
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { prisma } from '../../../config/database';
|
||||
import { authenticate, requireAdminRole, optionalAuth } from '../middleware/auth';
|
||||
import { z } from 'zod';
|
||||
@ -20,12 +20,12 @@ interface ListVideosQuery {
|
||||
|
||||
export async function videosRoutes(fastify: FastifyInstance) {
|
||||
// List videos (admin only)
|
||||
fastify.get(
|
||||
fastify.get<{ Querystring: ListVideosQuery }>(
|
||||
'/',
|
||||
{
|
||||
preHandler: requireAdminRole,
|
||||
},
|
||||
async (request: FastifyRequest<{ Querystring: ListVideosQuery }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const limit = parseInt(request.query.limit || '50');
|
||||
const offset = parseInt(request.query.offset || '0');
|
||||
const search = request.query.search;
|
||||
@ -91,7 +91,7 @@ export async function videosRoutes(fastify: FastifyInstance) {
|
||||
// Map videos to include thumbnailUrl
|
||||
const videosWithThumbnails = videos.map((video) => ({
|
||||
...video,
|
||||
duration: video.durationSeconds, // Add duration alias for frontend
|
||||
duration: video.durationSeconds ?? 0, // Add duration alias for frontend
|
||||
thumbnailUrl: video.thumbnailPath ? `/media/videos/${video.id}/thumbnail` : null,
|
||||
}));
|
||||
|
||||
@ -105,12 +105,12 @@ export async function videosRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// Get single video (admin only for now)
|
||||
fastify.get(
|
||||
fastify.get<{ Params: { id: string } }>(
|
||||
'/:id',
|
||||
{
|
||||
preHandler: requireAdminRole,
|
||||
},
|
||||
async (request: FastifyRequest<{ Params: { id: string } }>, reply) => {
|
||||
async (request, reply) => {
|
||||
const videoId = parseInt(request.params.id);
|
||||
|
||||
const video = await prisma.video.findUnique({
|
||||
@ -124,7 +124,7 @@ export async function videosRoutes(fastify: FastifyInstance) {
|
||||
return {
|
||||
video: {
|
||||
...video,
|
||||
duration: video.durationSeconds,
|
||||
duration: video.durationSeconds ?? 0,
|
||||
thumbnailUrl: video.thumbnailPath ? `/media/videos/${video.id}/thumbnail` : null,
|
||||
},
|
||||
};
|
||||
@ -137,7 +137,7 @@ export async function videosRoutes(fastify: FastifyInstance) {
|
||||
{
|
||||
preHandler: requireAdminRole,
|
||||
},
|
||||
async (request: FastifyRequest, reply) => {
|
||||
async (request, reply) => {
|
||||
const videos = await prisma.video.findMany({
|
||||
where: {
|
||||
producer: { not: null },
|
||||
@ -153,7 +153,7 @@ export async function videosRoutes(fastify: FastifyInstance) {
|
||||
);
|
||||
|
||||
// Health check for videos routes
|
||||
fastify.get('/health', async (request: FastifyRequest, reply) => {
|
||||
fastify.get('/health', async (request, reply) => {
|
||||
// Test database connection by counting videos
|
||||
const count = await prisma.video.count();
|
||||
|
||||
@ -387,8 +387,8 @@ export async function videosRoutes(fastify: FastifyInstance) {
|
||||
const thumbnailPath = await ThumbnailService.generateThumbnail({
|
||||
videoPath: video.path,
|
||||
videoId: video.id,
|
||||
duration: video.durationSeconds,
|
||||
orientation: video.orientation,
|
||||
duration: video.durationSeconds ?? 0,
|
||||
orientation: video.orientation ?? '',
|
||||
});
|
||||
|
||||
// Update video with thumbnail path
|
||||
@ -444,8 +444,8 @@ export async function videosRoutes(fastify: FastifyInstance) {
|
||||
const thumbnailPath = await ThumbnailService.generateThumbnail({
|
||||
videoPath: video.path,
|
||||
videoId: video.id,
|
||||
duration: video.durationSeconds,
|
||||
orientation: video.orientation,
|
||||
duration: video.durationSeconds ?? 0,
|
||||
orientation: video.orientation ?? '',
|
||||
});
|
||||
|
||||
// Update video with thumbnail path
|
||||
|
||||
@ -137,13 +137,14 @@ export class VideoAnalyticsService {
|
||||
where: { videoId },
|
||||
}),
|
||||
// Unique viewers (registered users only)
|
||||
prisma.videoView.count({
|
||||
prisma.videoView.findMany({
|
||||
where: {
|
||||
videoId,
|
||||
userId: { not: null },
|
||||
},
|
||||
distinct: ['userId'],
|
||||
}),
|
||||
select: { userId: true },
|
||||
}).then(views => views.length),
|
||||
// Total watch time
|
||||
prisma.videoView.aggregate({
|
||||
where: { videoId },
|
||||
|
||||
@ -209,6 +209,13 @@ async function exportToMkDocs(opts: ExportOptions): Promise<string> {
|
||||
content = wrapInMaterialOverride(html, css);
|
||||
}
|
||||
|
||||
// Rewrite relative media/gallery URLs to absolute for MkDocs context
|
||||
const adminUrl = env.ADMIN_URL || 'http://localhost:3000';
|
||||
content = content.replace(/src="\/media\/public\//g, `src="${adminUrl}/media/public/`);
|
||||
content = content.replace(/href="\/gallery\/watch\//g, `href="${adminUrl}/gallery/watch/`);
|
||||
content = content.replace(/href="\/gallery\?expanded=/g, `href="${adminUrl}/gallery?expanded=`);
|
||||
content = content.replace(/src="http:\/\/localhost:4100\//g, `src="${adminUrl.replace(/:\d+$/, ':4100')}/`);
|
||||
|
||||
await fs.writeFile(filePath, content, 'utf-8');
|
||||
logger.info(`Exported landing page to MkDocs: ${mkdocsPath} (${editorMode}/${exportMode})`);
|
||||
|
||||
|
||||
@ -523,7 +523,7 @@ router.post('/setup', pangolinSetupLimiter, async (req: Request, res: Response)
|
||||
logger.warn(`Created ${fullDomain} but failed to set as publicly accessible:`, updateErr);
|
||||
}
|
||||
|
||||
created.push({ subdomain: def.subdomain || '(root)', name: def.name, resourceId: resource.resourceId || resource.siteResourceId });
|
||||
created.push({ subdomain: def.subdomain || '(root)', name: def.name, siteResourceId: resource.resourceId || resource.siteResourceId });
|
||||
logger.info(`Created HTTP proxy resource: ${fullDomain}`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Unknown error';
|
||||
@ -632,7 +632,7 @@ router.post('/sync', pangolinSetupLimiter, async (_req: Request, res: Response)
|
||||
|
||||
if (resourceNeedsUpdate(existingResource, desired)) {
|
||||
try {
|
||||
await pangolinClient.updateResource(existingResource.resourceId, {
|
||||
await pangolinClient.updateResource(existingResource.resourceId!, {
|
||||
name: desired.name,
|
||||
ssl: desired.ssl,
|
||||
proxyPort: desired.proxyPort,
|
||||
@ -825,7 +825,7 @@ router.post('/test-2step', pangolinSetupLimiter, async (req: Request, res: Respo
|
||||
logger.info(`Target payload: ${JSON.stringify(createTargetPayload)}`);
|
||||
try {
|
||||
logger.info('Attempting target creation with standard endpoint...');
|
||||
const createdTarget = await pangolinClient.createTarget(resourceId, createTargetPayload);
|
||||
const createdTarget = await pangolinClient.createTarget(resourceId!, createTargetPayload);
|
||||
logger.info(`✅ Step 2 Success: Target created (standard endpoint)`);
|
||||
(results.steps as any).push({
|
||||
step: 2,
|
||||
@ -900,7 +900,7 @@ router.post('/test-2step', pangolinSetupLimiter, async (req: Request, res: Respo
|
||||
// --- Step 3: Verify resource was created ---
|
||||
logger.info('Step 3: Verifying resource exists');
|
||||
try {
|
||||
const verifiedResource = await pangolinClient.getResource(resourceId);
|
||||
const verifiedResource = await pangolinClient.getResource(resourceId!);
|
||||
logger.info(`✅ Step 3 Success: Resource verified`);
|
||||
(results.steps as any).push({
|
||||
step: 3,
|
||||
@ -922,7 +922,7 @@ router.post('/test-2step', pangolinSetupLimiter, async (req: Request, res: Respo
|
||||
// --- Step 4: Cleanup ---
|
||||
logger.info('Step 4: Cleanup (deleting test resource)');
|
||||
try {
|
||||
await pangolinClient.deleteResource(resourceId);
|
||||
await pangolinClient.deleteResource(resourceId!);
|
||||
logger.info(`✅ Step 4 Success: Resource deleted`);
|
||||
(results.steps as any).push({
|
||||
step: 4,
|
||||
|
||||
@ -17,13 +17,14 @@ router.get(
|
||||
'/status',
|
||||
async (_req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const [nocodbOnline, n8nOnline, giteaOnline, mailhogOnline, miniqrOnline, excalidrawOnline] = await Promise.all([
|
||||
const [nocodbOnline, n8nOnline, giteaOnline, mailhogOnline, miniqrOnline, excalidrawOnline, homepageOnline] = await Promise.all([
|
||||
isServiceOnline(env.NOCODB_URL),
|
||||
isServiceOnline(env.N8N_URL),
|
||||
isServiceOnline(env.GITEA_URL),
|
||||
isServiceOnline(env.MAILHOG_URL),
|
||||
isServiceOnline(env.MINI_QR_URL),
|
||||
isServiceOnline(env.EXCALIDRAW_URL),
|
||||
isServiceOnline(env.HOMEPAGE_URL),
|
||||
]);
|
||||
|
||||
// Update Prometheus gauges
|
||||
@ -33,6 +34,7 @@ router.get(
|
||||
setServiceUp('mailhog', mailhogOnline);
|
||||
setServiceUp('miniqr', miniqrOnline);
|
||||
setServiceUp('excalidraw', excalidrawOnline);
|
||||
setServiceUp('homepage', homepageOnline);
|
||||
|
||||
res.json({
|
||||
nocodb: { online: nocodbOnline, url: env.NOCODB_URL },
|
||||
@ -41,6 +43,7 @@ router.get(
|
||||
mailhog: { online: mailhogOnline, url: env.MAILHOG_URL },
|
||||
miniqr: { online: miniqrOnline, url: env.MINI_QR_URL },
|
||||
excalidraw: { online: excalidrawOnline, url: env.EXCALIDRAW_URL },
|
||||
homepage: { online: homepageOnline, url: env.HOMEPAGE_URL },
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('Failed to check services status', err);
|
||||
@ -82,6 +85,9 @@ router.get(
|
||||
// Alertmanager (alert routing)
|
||||
alertmanagerPort: 9093,
|
||||
alertmanagerSubdomain: 'alertmanager',
|
||||
// Homepage (service dashboard)
|
||||
homepagePort: env.HOMEPAGE_EMBED_PORT,
|
||||
homepageSubdomain: 'home',
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@ -187,18 +187,46 @@ class EmailService {
|
||||
title: string;
|
||||
thumbnailUrl: string;
|
||||
streamUrl: string;
|
||||
durationSeconds?: number;
|
||||
quality?: string;
|
||||
viewCount?: number;
|
||||
} | null> {
|
||||
try {
|
||||
const mediaApiUrl = env.MEDIA_API_PUBLIC_URL || 'http://media-api:4100';
|
||||
const response = await fetch(`${mediaApiUrl}/api/videos/${videoId}/metadata`);
|
||||
if (!response.ok) return null;
|
||||
return await response.json();
|
||||
return await response.json() as {
|
||||
id: number;
|
||||
title: string;
|
||||
thumbnailUrl: string;
|
||||
streamUrl: string;
|
||||
durationSeconds?: number;
|
||||
quality?: string;
|
||||
viewCount?: number;
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`Failed to fetch video ${videoId} metadata:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private formatDuration(seconds: number): string {
|
||||
if (!seconds || seconds <= 0) return '0:00';
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||
return `${m}:${String(s).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
private formatViewCount(count: number): string {
|
||||
if (!count || count <= 0) return '0 views';
|
||||
if (count === 1) return '1 view';
|
||||
if (count < 1000) return `${count} views`;
|
||||
if (count < 1_000_000) return `${(count / 1000).toFixed(1).replace(/\.0$/, '')}K views`;
|
||||
return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, '')}M views`;
|
||||
}
|
||||
|
||||
async processTemplate(
|
||||
template: string,
|
||||
vars: Record<string, string>,
|
||||
@ -213,23 +241,45 @@ class EmailService {
|
||||
const video = await this.getVideoMetadata(varDef.videoId);
|
||||
if (video) {
|
||||
const publicUrl = env.ADMIN_URL || 'http://localhost:3000';
|
||||
const watchUrl = `${publicUrl}/gallery/watch/${video.id}`;
|
||||
const duration = this.formatDuration(video.durationSeconds || 0);
|
||||
const quality = video.quality ? this.escapeHtml(video.quality) : '';
|
||||
const views = this.formatViewCount(video.viewCount || 0);
|
||||
const metaLine = [duration, quality, views].filter(Boolean).join(' • ');
|
||||
const videoHtml = `
|
||||
<table cellpadding="0" cellspacing="0" border="0" style="max-width: 600px; margin: 0 auto;">
|
||||
<table cellpadding="0" cellspacing="0" border="0" style="max-width: 480px; margin: 16px auto; border-radius: 8px; overflow: hidden; background-color: #1b2838;">
|
||||
<tr>
|
||||
<td>
|
||||
<a href="${publicUrl}/media/${video.id}" style="display: block; text-decoration: none;">
|
||||
<td style="padding: 0;">
|
||||
<a href="${watchUrl}" style="display: block; text-decoration: none;">
|
||||
<img src="${video.thumbnailUrl}"
|
||||
alt="${this.escapeHtml(video.title)}"
|
||||
style="width: 100%; max-width: 600px; height: auto; display: block; border-radius: 8px;" />
|
||||
width="480"
|
||||
style="width: 100%; max-width: 480px; height: auto; display: block;" />
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding-top: 12px; text-align: center;">
|
||||
<a href="${publicUrl}/media/${video.id}"
|
||||
style="display: inline-block; padding: 12px 24px; background-color: #0066cc; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 600;">
|
||||
▶ Watch Video: ${this.escapeHtml(video.title)}
|
||||
</a>
|
||||
<td style="padding: 12px 16px;">
|
||||
<table cellpadding="0" cellspacing="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="color: #ffffff; font-size: 15px; font-weight: 600; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
|
||||
${this.escapeHtml(video.title)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding-top: 6px; color: #8899aa; font-size: 12px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
|
||||
${metaLine}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding-top: 12px; text-align: center;">
|
||||
<a href="${watchUrl}"
|
||||
style="display: inline-block; padding: 10px 24px; background-color: #9d4edd; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 600; font-size: 14px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
|
||||
▶ Watch Video
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@ -281,7 +331,11 @@ class EmailService {
|
||||
const video = await this.getVideoMetadata(varDef.videoId);
|
||||
if (video) {
|
||||
const publicUrl = env.ADMIN_URL || 'http://localhost:3000';
|
||||
const textLink = `Watch Video: ${video.title}\n${publicUrl}/media/${video.id}`;
|
||||
const duration = this.formatDuration(video.durationSeconds || 0);
|
||||
const quality = video.quality || '';
|
||||
const views = this.formatViewCount(video.viewCount || 0);
|
||||
const meta = [duration, quality, views].filter(Boolean).join(', ');
|
||||
const textLink = `Watch "${video.title}" (${meta})\n${publicUrl}/gallery/watch/${video.id}`;
|
||||
result = result.replace(
|
||||
new RegExp(`\\{\\{${varDef.key}\\}\\}`, 'g'),
|
||||
textLink
|
||||
@ -416,16 +470,16 @@ class EmailService {
|
||||
|
||||
if (dbTemplate) {
|
||||
// Use database template
|
||||
html = this.processTemplate(dbTemplate.html, vars);
|
||||
text = this.processTemplate(dbTemplate.text, vars);
|
||||
html = await this.processTemplate(dbTemplate.html, vars);
|
||||
text = await this.processTemplate(dbTemplate.text, vars);
|
||||
subject = this.processSubject(dbTemplate.subject, vars);
|
||||
logger.debug('Using campaign email template from database');
|
||||
} else {
|
||||
// Fallback to filesystem
|
||||
const htmlTemplate = this.loadTemplate('campaign-email', 'html');
|
||||
const txtTemplate = this.loadTemplate('campaign-email', 'txt');
|
||||
html = this.processTemplate(htmlTemplate, vars);
|
||||
text = this.processTemplate(txtTemplate, vars);
|
||||
html = await this.processTemplate(htmlTemplate, vars);
|
||||
text = await this.processTemplate(txtTemplate, vars);
|
||||
subject = options.subject; // Use provided subject for filesystem fallback
|
||||
logger.warn('Using campaign email template from filesystem (fallback)');
|
||||
}
|
||||
@ -725,16 +779,16 @@ class EmailService {
|
||||
|
||||
if (dbTemplate) {
|
||||
// Use database template
|
||||
html = this.processTemplate(dbTemplate.html, vars);
|
||||
text = this.processTemplate(dbTemplate.text, vars);
|
||||
html = await this.processTemplate(dbTemplate.html, vars);
|
||||
text = await this.processTemplate(dbTemplate.text, vars);
|
||||
subject = this.processSubject(dbTemplate.subject, vars);
|
||||
logger.debug('Using response verification template from database');
|
||||
} else {
|
||||
// Fallback to filesystem
|
||||
const htmlTemplate = this.loadTemplate('response-verification', 'html');
|
||||
const txtTemplate = this.loadTemplate('response-verification', 'txt');
|
||||
html = this.processTemplate(htmlTemplate, vars);
|
||||
text = this.processTemplate(txtTemplate, vars);
|
||||
html = await this.processTemplate(htmlTemplate, vars);
|
||||
text = await this.processTemplate(txtTemplate, vars);
|
||||
subject = `Verify Response — ${options.campaignTitle}`;
|
||||
logger.warn('Using response verification template from filesystem (fallback)');
|
||||
}
|
||||
|
||||
@ -1,125 +1,163 @@
|
||||
---
|
||||
# Homepage Services Configuration
|
||||
# Tab 1: Production URLs (external/public access)
|
||||
# Tab 2: Local Development URLs (localhost with ports from config.sh)
|
||||
# Homepage Services Configuration — V2
|
||||
# Tab 1: Production URLs (external via Pangolin tunnel)
|
||||
# Tab 2: Local Development URLs (localhost with mapped ports)
|
||||
|
||||
#####################################
|
||||
# PRODUCTION - External URLs
|
||||
#####################################
|
||||
|
||||
- Production - Essential Tools:
|
||||
- Production - Application:
|
||||
|
||||
- Code Server:
|
||||
icon: mdi-code-braces
|
||||
href: "https://code.bnkserve.org"
|
||||
description: VS Code in the browser - Platform Editor
|
||||
container: code-server-changemaker
|
||||
- Admin GUI:
|
||||
icon: mdi-view-dashboard
|
||||
href: "https://app.cmlite.org"
|
||||
description: Admin dashboard & public pages
|
||||
container: changemaker-v2-admin
|
||||
|
||||
- Express API:
|
||||
icon: mdi-api
|
||||
href: "https://api.cmlite.org/api/health"
|
||||
description: Main V2 API (Prisma + PostgreSQL)
|
||||
container: changemaker-v2-api
|
||||
|
||||
- Media API:
|
||||
icon: mdi-movie-open
|
||||
href: "https://media.cmlite.org/health"
|
||||
description: Video library API (Fastify + Prisma)
|
||||
container: changemaker-media-api
|
||||
|
||||
- NocoDB:
|
||||
icon: mdi-database
|
||||
href: "https://db.bnkserve.org"
|
||||
description: No-code database platform
|
||||
container: changemakerlite-nocodb-1
|
||||
|
||||
- Map Server:
|
||||
icon: mdi-map
|
||||
href: "https://map.bnkserve.org"
|
||||
description: Map server for geospatial data
|
||||
container: nocodb-map-viewer
|
||||
|
||||
- Influence:
|
||||
icon: mdi-account-group
|
||||
href: "https://influence.bnkserve.org"
|
||||
description: Political influence and campaign management
|
||||
container: influence-app-1
|
||||
href: "https://db.cmlite.org"
|
||||
description: Read-only data browser
|
||||
container: changemaker-v2-nocodb
|
||||
|
||||
- Production - Content & Docs:
|
||||
|
||||
- Main Site:
|
||||
icon: mdi-web
|
||||
href: "https://bnkserve.org"
|
||||
description: CM-lite campaign website
|
||||
href: "https://cmlite.org"
|
||||
description: Documentation & marketing site (MkDocs)
|
||||
container: mkdocs-site-server-changemaker
|
||||
|
||||
- MkDocs (Live):
|
||||
icon: mdi-book-open-page-variant
|
||||
href: "https://docs.cmlite.org"
|
||||
description: Live documentation server with hot reload
|
||||
description: Live documentation with hot reload
|
||||
container: mkdocs-changemaker
|
||||
|
||||
- Code Server:
|
||||
icon: mdi-code-braces
|
||||
href: "https://code.cmlite.org"
|
||||
description: VS Code in the browser
|
||||
container: code-server-changemaker
|
||||
|
||||
- Mini QR:
|
||||
icon: mdi-qrcode
|
||||
href: "https://qr.bnkserve.org"
|
||||
href: "https://qr.cmlite.org"
|
||||
description: QR code generator
|
||||
container: mini-qr
|
||||
|
||||
- Excalidraw:
|
||||
icon: mdi-draw
|
||||
href: "https://draw.cmlite.org"
|
||||
description: Collaborative whiteboard
|
||||
container: excalidraw-changemaker
|
||||
|
||||
- Production - Automation & Services:
|
||||
|
||||
- Listmonk:
|
||||
icon: mdi-email-newsletter
|
||||
href: "https://listmonk.bnkserve.org"
|
||||
href: "https://listmonk.cmlite.org"
|
||||
description: Newsletter & mailing list manager
|
||||
container: listmonk_app
|
||||
|
||||
- Production - Automation:
|
||||
container: listmonk-app
|
||||
|
||||
- n8n:
|
||||
icon: mdi-robot-industrial
|
||||
href: "https://n8n.bnkserve.org"
|
||||
href: "https://n8n.cmlite.org"
|
||||
description: Workflow automation platform
|
||||
container: n8n-changemaker
|
||||
|
||||
- Gitea:
|
||||
icon: mdi-git
|
||||
href: "https://git.bnkserve.org"
|
||||
href: "https://git.cmlite.org"
|
||||
description: Git repository hosting
|
||||
container: gitea_changemaker
|
||||
container: gitea-changemaker
|
||||
|
||||
- MailHog:
|
||||
icon: mdi-email-check
|
||||
href: "https://mail.cmlite.org"
|
||||
description: Dev email capture (test mode)
|
||||
container: mailhog-changemaker
|
||||
|
||||
- Production - Infrastructure:
|
||||
|
||||
- Nginx:
|
||||
icon: mdi-server-network
|
||||
href: "#"
|
||||
description: Reverse proxy & subdomain routing
|
||||
container: changemaker-v2-nginx
|
||||
|
||||
- PostgreSQL (V2):
|
||||
icon: mdi-database-outline
|
||||
href: "#"
|
||||
description: Main application database
|
||||
container: changemaker-v2-postgres
|
||||
|
||||
- PostgreSQL (Listmonk):
|
||||
icon: mdi-database-outline
|
||||
href: "#"
|
||||
description: Database for Listmonk
|
||||
container: listmonk_db
|
||||
container: listmonk-db
|
||||
|
||||
- PostgreSQL (NocoDB):
|
||||
- MySQL (Gitea):
|
||||
icon: mdi-database-outline
|
||||
href: "#"
|
||||
description: Database for NocoDB
|
||||
container: changemakerlite-root_db-1
|
||||
description: Database for Gitea
|
||||
container: gitea-mysql
|
||||
|
||||
- Redis:
|
||||
icon: mdi-database-sync
|
||||
href: "#"
|
||||
description: Shared cache & session storage
|
||||
description: Cache, rate limiting & BullMQ
|
||||
container: redis-changemaker
|
||||
|
||||
- Newt:
|
||||
icon: mdi-tunnel
|
||||
href: "#"
|
||||
description: Pangolin tunnel connector
|
||||
container: newt-changemaker
|
||||
|
||||
- Production - Monitoring:
|
||||
|
||||
- Prometheus:
|
||||
icon: mdi-chart-line
|
||||
href: "https://prometheus.bnkserve.org"
|
||||
description: Metrics collection & time-series database
|
||||
container: prometheus-changemaker
|
||||
|
||||
- Grafana:
|
||||
icon: mdi-chart-box
|
||||
href: "https://grafana.bnkserve.org"
|
||||
href: "https://grafana.cmlite.org"
|
||||
description: Monitoring dashboards & visualizations
|
||||
container: grafana-changemaker
|
||||
|
||||
- Prometheus:
|
||||
icon: mdi-chart-line
|
||||
href: "#"
|
||||
description: Metrics collection & time-series database
|
||||
container: prometheus-changemaker
|
||||
|
||||
- Alertmanager:
|
||||
icon: mdi-bell-alert
|
||||
href: "https://alertmanager.bnkserve.org"
|
||||
href: "#"
|
||||
description: Alert routing & notification management
|
||||
container: alertmanager-changemaker
|
||||
|
||||
- Gotify:
|
||||
icon: mdi-cellphone-message
|
||||
href: "https://gotify.bnkserve.org"
|
||||
href: "#"
|
||||
description: Self-hosted push notifications
|
||||
container: gotify-changemaker
|
||||
|
||||
- cAdvisor:
|
||||
icon: mdi-docker
|
||||
href: "https://cadvisor.bnkserve.org"
|
||||
href: "#"
|
||||
description: Container resource metrics
|
||||
container: cadvisor-changemaker
|
||||
|
||||
@ -139,31 +177,31 @@
|
||||
# LOCAL DEVELOPMENT - Localhost URLs
|
||||
#####################################
|
||||
|
||||
- Local - Essential Tools:
|
||||
- Local - Application:
|
||||
|
||||
- Code Server:
|
||||
icon: mdi-code-braces
|
||||
href: "http://localhost:8888"
|
||||
description: VS Code in the browser (port 8888)
|
||||
container: code-server-changemaker
|
||||
- Admin GUI:
|
||||
icon: mdi-view-dashboard
|
||||
href: "http://localhost:3000"
|
||||
description: Admin dashboard & public pages (port 3000)
|
||||
container: changemaker-v2-admin
|
||||
|
||||
- Express API:
|
||||
icon: mdi-api
|
||||
href: "http://localhost:4000/api/health"
|
||||
description: Main V2 API (port 4000)
|
||||
container: changemaker-v2-api
|
||||
|
||||
- Media API:
|
||||
icon: mdi-movie-open
|
||||
href: "http://localhost:4100/health"
|
||||
description: Video library API (port 4100)
|
||||
container: changemaker-media-api
|
||||
|
||||
- NocoDB:
|
||||
icon: mdi-database
|
||||
href: "http://localhost:8090"
|
||||
description: No-code database platform (port 8090)
|
||||
container: changemakerlite-nocodb-1
|
||||
|
||||
- Map Server:
|
||||
icon: mdi-map
|
||||
href: "http://localhost:3000"
|
||||
description: Map server for geospatial data (port 3000)
|
||||
container: nocodb-map-viewer
|
||||
|
||||
- Influence:
|
||||
icon: mdi-account-group
|
||||
href: "http://localhost:3333"
|
||||
description: Political influence and campaign management (port 3333)
|
||||
container: influence-app-1
|
||||
href: "http://localhost:8091"
|
||||
description: Read-only data browser (port 8091)
|
||||
container: changemaker-v2-nocodb
|
||||
|
||||
- Homepage:
|
||||
icon: mdi-home
|
||||
@ -175,29 +213,41 @@
|
||||
|
||||
- Main Site:
|
||||
icon: mdi-web
|
||||
href: "http://localhost:4001"
|
||||
description: CM-lite campaign website (port 4001)
|
||||
href: "http://localhost:4004"
|
||||
description: Documentation site (port 4004)
|
||||
container: mkdocs-site-server-changemaker
|
||||
|
||||
- MkDocs (Live):
|
||||
icon: mdi-book-open-page-variant
|
||||
href: "http://localhost:4000"
|
||||
description: Live documentation with hot reload (port 4000)
|
||||
href: "http://localhost:4003"
|
||||
description: Live documentation with hot reload (port 4003)
|
||||
container: mkdocs-changemaker
|
||||
|
||||
- Code Server:
|
||||
icon: mdi-code-braces
|
||||
href: "http://localhost:8888"
|
||||
description: VS Code in the browser (port 8888)
|
||||
container: code-server-changemaker
|
||||
|
||||
- Mini QR:
|
||||
icon: mdi-qrcode
|
||||
href: "http://localhost:8089"
|
||||
description: QR code generator (port 8089)
|
||||
container: mini-qr
|
||||
|
||||
- Excalidraw:
|
||||
icon: mdi-draw
|
||||
href: "http://localhost:8090"
|
||||
description: Collaborative whiteboard (port 8090)
|
||||
container: excalidraw-changemaker
|
||||
|
||||
- Local - Automation & Services:
|
||||
|
||||
- Listmonk:
|
||||
icon: mdi-email-newsletter
|
||||
href: "http://localhost:9000"
|
||||
description: Newsletter & mailing list manager (port 9000)
|
||||
container: listmonk_app
|
||||
|
||||
- Local - Automation:
|
||||
href: "http://localhost:9001"
|
||||
description: Newsletter & mailing list manager (port 9001)
|
||||
container: listmonk-app
|
||||
|
||||
- n8n:
|
||||
icon: mdi-robot-industrial
|
||||
@ -209,50 +259,76 @@
|
||||
icon: mdi-git
|
||||
href: "http://localhost:3030"
|
||||
description: Git repository hosting (port 3030)
|
||||
container: gitea_changemaker
|
||||
container: gitea-changemaker
|
||||
|
||||
- MailHog:
|
||||
icon: mdi-email-check
|
||||
href: "http://localhost:8025"
|
||||
description: Dev email capture (port 8025)
|
||||
container: mailhog-changemaker
|
||||
|
||||
- Local - Infrastructure:
|
||||
|
||||
- Nginx:
|
||||
icon: mdi-server-network
|
||||
href: "http://localhost:80"
|
||||
description: Reverse proxy & subdomain routing (port 80)
|
||||
container: changemaker-v2-nginx
|
||||
|
||||
- PostgreSQL (V2):
|
||||
icon: mdi-database-outline
|
||||
href: "#"
|
||||
description: Main application database (port 5433)
|
||||
container: changemaker-v2-postgres
|
||||
|
||||
- PostgreSQL (Listmonk):
|
||||
icon: mdi-database-outline
|
||||
href: "#"
|
||||
description: Database for Listmonk (port 5432)
|
||||
container: listmonk_db
|
||||
container: listmonk-db
|
||||
|
||||
- PostgreSQL (NocoDB):
|
||||
- MySQL (Gitea):
|
||||
icon: mdi-database-outline
|
||||
href: "#"
|
||||
description: Database for NocoDB
|
||||
container: changemakerlite-root_db-1
|
||||
description: Database for Gitea (internal)
|
||||
container: gitea-mysql
|
||||
|
||||
- Redis:
|
||||
icon: mdi-database-sync
|
||||
href: "#"
|
||||
description: Shared cache & session storage (port 6379)
|
||||
description: Cache, rate limiting & BullMQ (port 6379)
|
||||
container: redis-changemaker
|
||||
|
||||
- Local - Monitoring:
|
||||
- Newt:
|
||||
icon: mdi-tunnel
|
||||
href: "#"
|
||||
description: Pangolin tunnel connector
|
||||
container: newt-changemaker
|
||||
|
||||
- Prometheus:
|
||||
icon: mdi-chart-line
|
||||
href: "http://localhost:9090"
|
||||
description: Metrics collection & time-series database (port 9090)
|
||||
container: prometheus-changemaker
|
||||
- Local - Monitoring:
|
||||
|
||||
- Grafana:
|
||||
icon: mdi-chart-box
|
||||
href: "http://localhost:3001"
|
||||
description: Monitoring dashboards & visualizations (port 3001)
|
||||
description: Monitoring dashboards (port 3001)
|
||||
container: grafana-changemaker
|
||||
|
||||
- Prometheus:
|
||||
icon: mdi-chart-line
|
||||
href: "http://localhost:9090"
|
||||
description: Metrics collection (port 9090)
|
||||
container: prometheus-changemaker
|
||||
|
||||
- Alertmanager:
|
||||
icon: mdi-bell-alert
|
||||
href: "http://localhost:9093"
|
||||
description: Alert routing & notification management (port 9093)
|
||||
description: Alert routing (port 9093)
|
||||
container: alertmanager-changemaker
|
||||
|
||||
- Gotify:
|
||||
icon: mdi-cellphone-message
|
||||
href: "http://localhost:8889"
|
||||
description: Self-hosted push notifications (port 8889)
|
||||
description: Push notifications (port 8889)
|
||||
container: gotify-changemaker
|
||||
|
||||
- cAdvisor:
|
||||
@ -264,11 +340,11 @@
|
||||
- Node Exporter:
|
||||
icon: mdi-server
|
||||
href: "http://localhost:9100/metrics"
|
||||
description: System-level metrics exporter (port 9100)
|
||||
description: System-level metrics (port 9100)
|
||||
container: node-exporter-changemaker
|
||||
|
||||
- Redis Exporter:
|
||||
icon: mdi-database-export
|
||||
href: "http://localhost:9121/metrics"
|
||||
description: Redis metrics exporter (port 9121)
|
||||
description: Redis metrics (port 9121)
|
||||
container: redis-exporter-changemaker
|
||||
|
||||
@ -18,28 +18,34 @@ cardBlur: xl # xs, md,
|
||||
headerStyle: boxed
|
||||
|
||||
layout:
|
||||
# Production Tab Groups - displayed as vertical columns
|
||||
Production - Essential Tools:
|
||||
# Production Tab Groups
|
||||
Production - Application:
|
||||
tab: Production
|
||||
style: column
|
||||
Production - Content & Docs:
|
||||
tab: Production
|
||||
style: column
|
||||
Production - Automation:
|
||||
Production - Automation & Services:
|
||||
tab: Production
|
||||
style: column
|
||||
Production - Infrastructure:
|
||||
tab: Production
|
||||
style: column
|
||||
Production - Monitoring:
|
||||
tab: Production
|
||||
style: column
|
||||
|
||||
# Local Development Tab Groups - displayed as vertical columns
|
||||
Local - Essential Tools:
|
||||
# Local Development Tab Groups
|
||||
Local - Application:
|
||||
tab: Local
|
||||
style: column
|
||||
Local - Content & Docs:
|
||||
tab: Local
|
||||
style: column
|
||||
Local - Automation:
|
||||
Local - Automation & Services:
|
||||
tab: Local
|
||||
style: column
|
||||
Local - Infrastructure:
|
||||
tab: Local
|
||||
style: column
|
||||
Local - Monitoring:
|
||||
|
||||
@ -126,9 +126,9 @@ groups:
|
||||
summary: "High memory usage"
|
||||
description: "Memory usage is above 85% ({{ $value | humanizePercentage }})."
|
||||
|
||||
# Container CPU throttling
|
||||
# Container CPU throttling (only Docker containers)
|
||||
- alert: ContainerCPUThrottling
|
||||
expr: rate(container_cpu_cfs_throttled_seconds_total[5m]) > 0.5
|
||||
expr: rate(container_cpu_cfs_throttled_seconds_total{name!=""}[5m]) > 0.5
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
@ -136,9 +136,9 @@ groups:
|
||||
summary: "Container is being CPU throttled"
|
||||
description: "Container {{ $labels.name }} is experiencing CPU throttling."
|
||||
|
||||
# Container memory usage high
|
||||
# Container memory usage high (only Docker containers with memory limits)
|
||||
- alert: ContainerMemoryHigh
|
||||
expr: (container_memory_usage_bytes / container_spec_memory_limit_bytes) > 0.90
|
||||
expr: (container_memory_usage_bytes{name!=""} / container_spec_memory_limit_bytes{name!=""}) > 0.90 and container_spec_memory_limit_bytes{name!=""} > 0
|
||||
for: 5m
|
||||
labels:
|
||||
severity: warning
|
||||
|
||||
@ -63,6 +63,8 @@ services:
|
||||
- EXCALIDRAW_URL=${EXCALIDRAW_URL:-http://excalidraw-changemaker:80}
|
||||
- EXCALIDRAW_PORT=${EXCALIDRAW_PORT:-8090}
|
||||
- EXCALIDRAW_EMBED_PORT=${EXCALIDRAW_EMBED_PORT:-8886}
|
||||
- HOMEPAGE_URL=${HOMEPAGE_URL:-http://homepage-changemaker:3000}
|
||||
- HOMEPAGE_EMBED_PORT=${HOMEPAGE_EMBED_PORT:-8887}
|
||||
volumes:
|
||||
- ./api:/app
|
||||
- /app/node_modules
|
||||
@ -186,6 +188,7 @@ services:
|
||||
- "8884:8884" # MailHog embed proxy
|
||||
- "8885:8885" # Mini QR embed proxy
|
||||
- "8886:8886" # Excalidraw embed proxy
|
||||
- "8887:8887" # Homepage embed proxy
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "--spider", "http://127.0.0.1:80/"]
|
||||
interval: 30s
|
||||
@ -421,6 +424,11 @@ services:
|
||||
- "${MKDOCS_PORT:-4003}:8000"
|
||||
environment:
|
||||
- SITE_URL=${BASE_DOMAIN:-https://cmlite.org}
|
||||
- ADMIN_PORT=${ADMIN_PORT:-3000}
|
||||
- ADMIN_URL=${ADMIN_URL:-}
|
||||
- MEDIA_API_PUBLIC_URL=${MEDIA_API_PUBLIC_URL:-http://localhost:4100}
|
||||
- MEDIA_API_PORT=${MEDIA_API_PORT:-4100}
|
||||
- BASE_DOMAIN=${BASE_DOMAIN:-}
|
||||
command: serve --dev-addr=0.0.0.0:8000 --watch-theme --livereload
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
|
||||
@ -1,886 +0,0 @@
|
||||
# Media Admin Features - Complete Guide
|
||||
|
||||
## Overview
|
||||
|
||||
The Video Admin Features system provides comprehensive content management capabilities for the Changemaker Lite video library. Implemented in February 2026, this system transforms the basic CRUD interface into a professional-grade video management platform with quick actions, scheduled publishing, and detailed analytics.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Quick Action Buttons](#quick-action-buttons)
|
||||
2. [Scheduled Publishing](#scheduled-publishing)
|
||||
3. [Video Analytics](#video-analytics)
|
||||
4. [Architecture](#architecture)
|
||||
5. [API Reference](#api-reference)
|
||||
6. [Security](#security)
|
||||
|
||||
---
|
||||
|
||||
## Quick Action Buttons
|
||||
|
||||
### Overview
|
||||
|
||||
Quick action buttons appear on video cards when hovering, providing instant access to common operations without navigating away from the library view.
|
||||
|
||||
### Features
|
||||
|
||||
**Primary Actions:**
|
||||
- **Edit** (E) - Modify video metadata, title, producer, creator
|
||||
- **Preview** (P) - Watch video with full analytics tracking
|
||||
- **Analytics** (A) - View quick statistics modal
|
||||
- **Schedule** (S) - Set publish/unpublish times
|
||||
|
||||
**Secondary Actions (Overflow Menu):**
|
||||
- **Duplicate** - Clone video with new title
|
||||
- **Generate Preview Link** - Create expiring share link (24h)
|
||||
- **Download** - Download original video file (coming soon)
|
||||
- **Generate Thumbnail** - Auto-generate thumbnail from video (coming soon)
|
||||
- **Reset Analytics** - Clear all view data and statistics
|
||||
- **Delete** - Permanently remove video
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
Quick actions support keyboard shortcuts when the library page is focused:
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `E` | Edit video |
|
||||
| `P` | Preview video |
|
||||
| `A` | Show analytics |
|
||||
| `S` | Schedule publishing |
|
||||
|
||||
**Note:** Shortcuts are disabled when typing in input fields or text areas.
|
||||
|
||||
### Preview Links
|
||||
|
||||
Preview links allow sharing videos with external parties without requiring authentication.
|
||||
|
||||
**Characteristics:**
|
||||
- JWT-based token authentication
|
||||
- 24-hour expiration (configurable via `VIDEO_PREVIEW_LINK_EXPIRY_HOURS`)
|
||||
- Automatically copied to clipboard
|
||||
- Single-use tracking (each open creates new view)
|
||||
|
||||
**Generating a Preview Link:**
|
||||
1. Hover over video card
|
||||
2. Click "More Actions" (three dots)
|
||||
3. Select "Generate Preview Link"
|
||||
4. Link is automatically copied to clipboard
|
||||
5. Modal displays link and expiry time
|
||||
|
||||
**Preview Link Format:**
|
||||
```
|
||||
https://media.cmlite.org/api/videos/preview/{token}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Scheduled Publishing
|
||||
|
||||
### Overview
|
||||
|
||||
The scheduled publishing system uses BullMQ job queue with Redis backend to automatically publish/unpublish videos at specific times with timezone support.
|
||||
|
||||
### Features
|
||||
|
||||
**Publishing Options:**
|
||||
- **Publish Now** - Immediately set video to published state
|
||||
- **Schedule Publish** - Set future publish date/time
|
||||
- **Schedule Unpublish** - Auto-unpublish after period (optional)
|
||||
- **Timezone Support** - 11 common timezones with auto-detection
|
||||
- **Calendar View** - Visual overview of all upcoming schedules
|
||||
|
||||
**Supported Timezones:**
|
||||
- UTC (Coordinated Universal Time)
|
||||
- EST (Eastern Standard Time)
|
||||
- CST (Central Standard Time)
|
||||
- MST (Mountain Standard Time)
|
||||
- PST (Pacific Standard Time)
|
||||
- Toronto, Vancouver (Canada)
|
||||
- GMT (London), CET (Paris)
|
||||
- JST (Tokyo), AEDT (Sydney)
|
||||
|
||||
### Using Scheduled Publishing
|
||||
|
||||
**Schedule a Video:**
|
||||
1. Click Schedule button on video card (clock icon)
|
||||
2. Toggle "Publish immediately" off
|
||||
3. Select timezone (defaults to your system timezone)
|
||||
4. Choose publish date/time
|
||||
5. Optionally enable "Auto-unpublish after period"
|
||||
6. Click "Schedule" button
|
||||
|
||||
**View Scheduled Videos:**
|
||||
1. Click "View Calendar" button in LibraryPage header
|
||||
2. Calendar shows badge counts for days with scheduled events
|
||||
3. Click a date to view scheduled publish/unpublish events
|
||||
4. Cancel individual schedules from the list
|
||||
|
||||
**Schedule Badge:**
|
||||
- Appears on video cards when publish/unpublish is scheduled
|
||||
- Shows time until scheduled action
|
||||
- Clock icon with color coding:
|
||||
- Green: Scheduled to publish
|
||||
- Orange: Scheduled to unpublish
|
||||
- Red: Overdue (failed to execute)
|
||||
|
||||
### BullMQ Job Queue
|
||||
|
||||
**Queue Configuration:**
|
||||
- Queue name: `video-schedules`
|
||||
- Redis connection: Shared with email queue
|
||||
- Job retry: 3 attempts with exponential backoff
|
||||
- Job timeout: 30 seconds
|
||||
|
||||
**Job Processing:**
|
||||
1. Schedule created → Job added to queue with delayed timestamp
|
||||
2. Redis stores job until scheduled time
|
||||
3. Worker picks up job at scheduled time
|
||||
4. Updates video `isPublished` field
|
||||
5. Records execution in `VideoScheduleHistory`
|
||||
6. Removes job from queue
|
||||
|
||||
**Monitoring:**
|
||||
See `MediaJobsPage` at `/app/media/jobs` for queue monitoring (coming soon).
|
||||
|
||||
---
|
||||
|
||||
## Video Analytics
|
||||
|
||||
### Overview
|
||||
|
||||
Comprehensive analytics tracking system that records views, watch time, user engagement, and traffic sources with privacy-focused design.
|
||||
|
||||
### Metrics Tracked
|
||||
|
||||
**Overview Statistics:**
|
||||
- **Total Views** - Number of times video was played
|
||||
- **Unique Viewers** - Deduplicated viewers (by IP hash or user ID)
|
||||
- **Average Watch Time** - Mean watch duration across all views
|
||||
- **Completion Rate** - Percentage of viewers who watched ≥95%
|
||||
- **Total Watch Time** - Cumulative watch time across all views
|
||||
|
||||
**Per-View Data:**
|
||||
- IP address (SHA-256 hashed for privacy)
|
||||
- User agent (truncated, version numbers removed)
|
||||
- Referrer URL (traffic source)
|
||||
- User ID (for logged-in users)
|
||||
- Watch time in seconds
|
||||
- Completion status (boolean)
|
||||
|
||||
**Event Tracking:**
|
||||
- **Play** - Video started/resumed
|
||||
- **Pause** - Video paused
|
||||
- **Seek** - User skipped forward/backward (with timestamp)
|
||||
- **Complete** - Video watched to 95%+
|
||||
|
||||
### Privacy & GDPR Compliance
|
||||
|
||||
**Data Protection Measures:**
|
||||
1. **IP Address Hashing** - All IP addresses hashed with SHA-256 before storage
|
||||
2. **User Agent Truncation** - Version numbers and detailed info removed
|
||||
3. **Anonymous Aggregation** - Anonymous views separated from registered users
|
||||
4. **90-Day Retention** - Configurable data retention policy (default: 90 days)
|
||||
5. **Do Not Track** - Respects DNT header (optional)
|
||||
6. **User Opt-Out** - Users can disable analytics tracking in settings
|
||||
|
||||
**Compliance Features:**
|
||||
- No personally identifiable information (PII) stored for anonymous users
|
||||
- Registered user tracking requires explicit consent
|
||||
- GDPR Article 17 "Right to be forgotten" via reset analytics
|
||||
- Transparent data collection disclosure
|
||||
|
||||
### Analytics Dashboard
|
||||
|
||||
**Quick Analytics Modal** (accessible from video card):
|
||||
- Overview stats (4 cards)
|
||||
- Top referrers (up to 5 sources)
|
||||
- Recent registered viewers (up to 10)
|
||||
|
||||
**Detailed Analytics Modal** (click Analytics button):
|
||||
- **Overview Tab:**
|
||||
- 4 overview stat cards
|
||||
- Total watch time card
|
||||
- Top referrers table (sortable)
|
||||
|
||||
- **Charts Tab:**
|
||||
- Views over time (area chart, last 30 days)
|
||||
- Traffic sources distribution (pie chart)
|
||||
|
||||
- **Viewers Tab:**
|
||||
- Full registered viewers table
|
||||
- Sortable by watch time, completion status
|
||||
- Filter by completed/partial views
|
||||
|
||||
**Global Analytics Dashboard** (`/app/media/analytics`):
|
||||
- Platform-wide statistics (total videos, views, watch time)
|
||||
- Average completion rate across all videos
|
||||
- Top 10 videos by views or watch time (switchable)
|
||||
- Ranking system with medal icons (🥇🥈🥉)
|
||||
|
||||
### Tracking Implementation
|
||||
|
||||
**Client-Side Tracking:**
|
||||
```typescript
|
||||
// Record view when modal opens
|
||||
await mediaApi.post('/track/view', {
|
||||
videoId: video.id,
|
||||
referer: document.referrer || undefined,
|
||||
});
|
||||
|
||||
// Record events
|
||||
await mediaApi.post('/track/event', {
|
||||
videoId: video.id,
|
||||
viewId: viewId,
|
||||
eventType: 'play', // or 'pause', 'seek', 'complete'
|
||||
timestamp: videoElement.currentTime,
|
||||
});
|
||||
|
||||
// Heartbeat every 10 seconds
|
||||
setInterval(() => {
|
||||
navigator.sendBeacon(
|
||||
'/api/track/heartbeat',
|
||||
JSON.stringify({ viewId, watchTimeSeconds: currentTime })
|
||||
);
|
||||
}, 10000);
|
||||
```
|
||||
|
||||
**Tracking Endpoints:**
|
||||
- `POST /track/view` - Record video view start
|
||||
- `POST /track/event` - Record video event (play, pause, etc.)
|
||||
- `POST /track/heartbeat` - Update watch time (high frequency)
|
||||
- `POST /track/batch` - Batch event submission (coming soon)
|
||||
|
||||
**Rate Limiting:**
|
||||
- `/track/view`: 100 requests/minute per IP
|
||||
- `/track/event`: 100 requests/minute per IP
|
||||
- `/track/heartbeat`: 200 requests/minute per IP (higher for frequent updates)
|
||||
|
||||
### Analytics Aggregation
|
||||
|
||||
Analytics are aggregated in real-time via the `VideoAnalyticsService`:
|
||||
|
||||
```typescript
|
||||
class VideoAnalyticsService {
|
||||
async aggregateVideoAnalytics(videoId: number) {
|
||||
// Aggregate from VideoView and VideoEvent tables
|
||||
// Update Video model fields:
|
||||
// - uniqueViewers
|
||||
// - totalWatchTimeSeconds
|
||||
// - averageWatchTimeSeconds
|
||||
// - completionRate
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Aggregation Triggers:**
|
||||
- After each video view completes
|
||||
- On-demand via API endpoint
|
||||
- Scheduled batch job (nightly, coming soon)
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### System Design
|
||||
|
||||
**Technology Stack:**
|
||||
- **Backend:** Fastify Media API (port 4100) with Prisma ORM
|
||||
- **Frontend:** React + Ant Design + Zustand
|
||||
- **Job Queue:** BullMQ with Redis
|
||||
- **Database:** PostgreSQL 16 with Prisma migrations
|
||||
- **Charts:** Recharts library
|
||||
|
||||
### Database Schema
|
||||
|
||||
**New Models:**
|
||||
|
||||
```prisma
|
||||
model Video {
|
||||
// ... existing fields ...
|
||||
|
||||
// Publishing
|
||||
scheduledPublishAt DateTime?
|
||||
scheduledUnpublishAt DateTime?
|
||||
|
||||
// Analytics
|
||||
uniqueViewers Int @default(0)
|
||||
totalWatchTimeSeconds Int @default(0)
|
||||
averageWatchTimeSeconds Decimal @default(0) @db.Decimal(10, 2)
|
||||
completionRate Decimal @default(0) @db.Decimal(5, 2)
|
||||
|
||||
// Relations
|
||||
videoViews VideoView[]
|
||||
videoEvents VideoEvent[]
|
||||
}
|
||||
|
||||
model VideoView {
|
||||
id Int @id @default(autoincrement())
|
||||
videoId Int
|
||||
userId Int?
|
||||
ipAddress String? @db.VarChar(45) // SHA-256 hash
|
||||
userAgent String? @db.Text
|
||||
referer String? @db.Text
|
||||
watchTimeSeconds Int @default(0)
|
||||
completed Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
video Video @relation(fields: [videoId], references: [id], onDelete: Cascade)
|
||||
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([videoId])
|
||||
@@index([userId])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model VideoEvent {
|
||||
id Int @id @default(autoincrement())
|
||||
videoId Int
|
||||
viewId Int?
|
||||
eventType String @db.VarChar(50) // play, pause, seek, complete
|
||||
timestamp Decimal @db.Decimal(10, 2) // Video timestamp in seconds
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
video Video @relation(fields: [videoId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([videoId])
|
||||
@@index([viewId])
|
||||
}
|
||||
|
||||
model VideoScheduleHistory {
|
||||
id Int @id @default(autoincrement())
|
||||
videoId Int
|
||||
action String @db.VarChar(20) // 'publish' or 'unpublish'
|
||||
scheduledFor DateTime
|
||||
executedAt DateTime?
|
||||
status String @db.VarChar(20) // 'pending', 'completed', 'failed', 'cancelled'
|
||||
error String? @db.Text
|
||||
scheduledByUserId Int
|
||||
|
||||
video Video @relation(fields: [videoId], references: [id], onDelete: Cascade)
|
||||
scheduledBy User @relation(fields: [scheduledByUserId], references: [id])
|
||||
|
||||
@@index([videoId])
|
||||
@@index([scheduledFor])
|
||||
@@index([status])
|
||||
}
|
||||
```
|
||||
|
||||
### File Structure
|
||||
|
||||
**Backend Services:**
|
||||
```
|
||||
api/src/modules/media/
|
||||
├── services/
|
||||
│ ├── video-analytics.service.ts # Analytics aggregation
|
||||
│ └── ffprobe.service.ts # Video metadata (existing)
|
||||
├── routes/
|
||||
│ ├── videos.routes.ts # Video CRUD (existing)
|
||||
│ ├── video-actions.routes.ts # Quick actions (duplicate, preview link, etc.)
|
||||
│ ├── video-schedule.routes.ts # Schedule management
|
||||
│ ├── video-analytics.routes.ts # Analytics queries (admin)
|
||||
│ └── video-tracking.routes.ts # Public tracking endpoints
|
||||
└── db/
|
||||
└── schema.ts # Drizzle schema (existing)
|
||||
|
||||
api/src/services/
|
||||
└── video-schedule-queue.service.ts # BullMQ queue + worker
|
||||
```
|
||||
|
||||
**Frontend Components:**
|
||||
```
|
||||
admin/src/components/media/
|
||||
├── VideoCard.tsx # Enhanced with actions overlay
|
||||
├── VideoActions.tsx # Action buttons component
|
||||
├── QuickAnalyticsModal.tsx # Quick stats modal
|
||||
├── SchedulePublishModal.tsx # Schedule picker with timezone
|
||||
├── ScheduleCalendarModal.tsx # Calendar view
|
||||
├── ScheduleBadge.tsx # Schedule status badge
|
||||
├── VideoAnalyticsModal.tsx # Detailed analytics (3 tabs)
|
||||
├── AnalyticsChart.tsx # Recharts wrapper
|
||||
├── ViewersTable.tsx # Registered viewers table
|
||||
└── VideoViewerModal.tsx # Enhanced with tracking
|
||||
|
||||
admin/src/pages/media/
|
||||
├── LibraryPage.tsx # Enhanced with calendar button
|
||||
└── AnalyticsDashboardPage.tsx # Global analytics dashboard
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
### Quick Actions Endpoints
|
||||
|
||||
**Duplicate Video**
|
||||
```http
|
||||
POST /videos/:id/duplicate
|
||||
Authorization: Bearer {admin_token}
|
||||
|
||||
Response:
|
||||
{
|
||||
"id": 123,
|
||||
"title": "Original Title (Copy)",
|
||||
"filename": "uuid-copy.mp4",
|
||||
// ... other video fields
|
||||
}
|
||||
```
|
||||
|
||||
**Generate Preview Link**
|
||||
```http
|
||||
GET /videos/:id/preview-link
|
||||
Authorization: Bearer {admin_token}
|
||||
|
||||
Response:
|
||||
{
|
||||
"previewUrl": "https://media.cmlite.org/api/videos/preview/{jwt_token}",
|
||||
"expiryHours": 24
|
||||
}
|
||||
```
|
||||
|
||||
**Reset Analytics**
|
||||
```http
|
||||
POST /videos/:id/reset-analytics
|
||||
Authorization: Bearer {admin_token}
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"message": "Analytics reset successfully"
|
||||
}
|
||||
```
|
||||
|
||||
**Get Video Analytics**
|
||||
```http
|
||||
GET /videos/:id/analytics
|
||||
Authorization: Bearer {admin_token}
|
||||
|
||||
Query Params:
|
||||
- startDate (optional): ISO date string
|
||||
- endDate (optional): ISO date string
|
||||
|
||||
Response:
|
||||
{
|
||||
"overview": {
|
||||
"totalViews": 1234,
|
||||
"uniqueViewers": 567,
|
||||
"averageWatchTime": 180.5,
|
||||
"completionRate": 67.3,
|
||||
"totalWatchTime": 123456
|
||||
},
|
||||
"topReferrers": [
|
||||
{ "referer": "google.com", "count": 45 },
|
||||
{ "referer": "facebook.com", "count": 23 }
|
||||
],
|
||||
"registeredViewers": [
|
||||
{
|
||||
"userId": 1,
|
||||
"userName": "John Doe",
|
||||
"userEmail": "john@example.com",
|
||||
"watchTime": 300,
|
||||
"completed": true
|
||||
}
|
||||
],
|
||||
"viewsOverTime": [
|
||||
{ "date": "2026-02-01", "count": 12 },
|
||||
{ "date": "2026-02-02", "count": 18 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Schedule Management Endpoints
|
||||
|
||||
**Schedule Publish**
|
||||
```http
|
||||
POST /videos/:id/schedule-publish
|
||||
Authorization: Bearer {admin_token}
|
||||
|
||||
Body:
|
||||
{
|
||||
"publishAt": "2026-02-20T14:00:00Z",
|
||||
"timezone": "America/New_York"
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"jobId": "schedule:publish:123:abc-def",
|
||||
"scheduledFor": "2026-02-20T14:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Schedule Unpublish**
|
||||
```http
|
||||
POST /videos/:id/schedule-unpublish
|
||||
Authorization: Bearer {admin_token}
|
||||
|
||||
Body:
|
||||
{
|
||||
"unpublishAt": "2026-03-01T00:00:00Z",
|
||||
"timezone": "UTC"
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"jobId": "schedule:unpublish:123:xyz-123",
|
||||
"scheduledFor": "2026-03-01T00:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Cancel Schedule**
|
||||
```http
|
||||
DELETE /videos/:id/schedule/:action
|
||||
Authorization: Bearer {admin_token}
|
||||
|
||||
Params:
|
||||
- action: 'publish' or 'unpublish'
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true,
|
||||
"message": "publish schedule cancelled"
|
||||
}
|
||||
```
|
||||
|
||||
**Get Upcoming Schedules**
|
||||
```http
|
||||
GET /videos/schedules/upcoming
|
||||
Authorization: Bearer {admin_token}
|
||||
|
||||
Query Params:
|
||||
- limit (optional): default 100
|
||||
|
||||
Response:
|
||||
{
|
||||
"schedules": [
|
||||
{
|
||||
"jobId": "schedule:publish:123:abc",
|
||||
"videoId": 123,
|
||||
"videoTitle": "My Video",
|
||||
"action": "publish",
|
||||
"scheduledFor": "2026-02-20T14:00:00Z",
|
||||
"status": "pending"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Get Schedule History**
|
||||
```http
|
||||
GET /videos/:id/schedule-history
|
||||
Authorization: Bearer {admin_token}
|
||||
|
||||
Response:
|
||||
{
|
||||
"history": [
|
||||
{
|
||||
"id": 1,
|
||||
"action": "publish",
|
||||
"scheduledFor": "2026-02-15T10:00:00Z",
|
||||
"executedAt": "2026-02-15T10:00:03Z",
|
||||
"status": "completed",
|
||||
"scheduledBy": "admin@example.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Analytics Tracking Endpoints (Public)
|
||||
|
||||
**Record View**
|
||||
```http
|
||||
POST /track/view
|
||||
Content-Type: application/json
|
||||
|
||||
Body:
|
||||
{
|
||||
"videoId": 123,
|
||||
"referer": "https://example.com" // optional
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"viewId": 456
|
||||
}
|
||||
```
|
||||
|
||||
**Record Event**
|
||||
```http
|
||||
POST /track/event
|
||||
Content-Type: application/json
|
||||
|
||||
Body:
|
||||
{
|
||||
"videoId": 123,
|
||||
"viewId": 456, // optional
|
||||
"eventType": "play", // 'play', 'pause', 'seek', 'complete'
|
||||
"timestamp": 45.5
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
**Update Watch Time (Heartbeat)**
|
||||
```http
|
||||
POST /track/heartbeat
|
||||
Content-Type: application/json
|
||||
|
||||
Body:
|
||||
{
|
||||
"viewId": 456,
|
||||
"watchTimeSeconds": 120
|
||||
}
|
||||
|
||||
Response:
|
||||
{
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
### Analytics Query Endpoints (Admin)
|
||||
|
||||
**Get Top Videos**
|
||||
```http
|
||||
GET /videos/analytics/top
|
||||
Authorization: Bearer {admin_token}
|
||||
|
||||
Query Params:
|
||||
- metric: 'views' or 'watchTime'
|
||||
- limit: default 10
|
||||
|
||||
Response:
|
||||
{
|
||||
"videos": [
|
||||
{
|
||||
"id": 123,
|
||||
"title": "Popular Video",
|
||||
"value": 1234 // views or watch time based on metric
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Get Analytics Overview**
|
||||
```http
|
||||
GET /videos/analytics/overview
|
||||
Authorization: Bearer {admin_token}
|
||||
|
||||
Response:
|
||||
{
|
||||
"totalVideos": 50,
|
||||
"totalViews": 12345,
|
||||
"totalWatchTimeSeconds": 567890,
|
||||
"averageCompletionRate": 65.4
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
|
||||
### Authorization
|
||||
|
||||
**Admin-Only Endpoints:**
|
||||
All quick action, schedule management, and analytics query endpoints require admin role:
|
||||
- `requireAdminRole` middleware (SUPER_ADMIN, INFLUENCE_ADMIN, MAP_ADMIN)
|
||||
|
||||
**Public Tracking Endpoints:**
|
||||
- No authentication required
|
||||
- Rate limited (100-200 req/min per IP)
|
||||
- Optional authentication via `optionalAuth` middleware (tracks user ID if logged in)
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
**Tracking Endpoints:**
|
||||
```typescript
|
||||
{
|
||||
'/track/view': { max: 100, windowMs: 60000 }, // 100/min
|
||||
'/track/event': { max: 100, windowMs: 60000 }, // 100/min
|
||||
'/track/heartbeat': { max: 200, windowMs: 60000 }, // 200/min (higher for frequent updates)
|
||||
}
|
||||
```
|
||||
|
||||
**Admin Endpoints:**
|
||||
- Covered by global admin rate limits (500 req/min)
|
||||
|
||||
### Preview Link Security
|
||||
|
||||
**JWT Token Structure:**
|
||||
```typescript
|
||||
{
|
||||
videoId: number,
|
||||
exp: number, // 24 hours from generation
|
||||
iat: number
|
||||
}
|
||||
```
|
||||
|
||||
**Validation:**
|
||||
- JWT signature verification
|
||||
- Expiration check (401 if expired)
|
||||
- Video existence check (404 if deleted)
|
||||
- Single-use recommended (no enforcement yet)
|
||||
|
||||
**Environment Configuration:**
|
||||
```env
|
||||
VIDEO_PREVIEW_LINK_EXPIRY_HOURS=24
|
||||
JWT_ACCESS_SECRET=your_secret_here # Used for signing preview tokens
|
||||
```
|
||||
|
||||
### Privacy Protection
|
||||
|
||||
**IP Address Hashing:**
|
||||
```typescript
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
function hashIpAddress(ipAddress: string): string {
|
||||
return createHash('sha256')
|
||||
.update(ipAddress)
|
||||
.digest('hex');
|
||||
}
|
||||
```
|
||||
|
||||
**User Agent Truncation:**
|
||||
```typescript
|
||||
function truncateUserAgent(userAgent: string): string {
|
||||
// Remove version numbers: "Chrome/91.0.4472.124" → "Chrome"
|
||||
return userAgent
|
||||
.replace(/\/[\d.]+/g, '')
|
||||
.substring(0, 200);
|
||||
}
|
||||
```
|
||||
|
||||
**Data Retention:**
|
||||
```typescript
|
||||
// Scheduled cleanup (nightly)
|
||||
async function cleanupOldAnalytics() {
|
||||
const retentionDays = parseInt(process.env.VIDEO_ANALYTICS_RETENTION_DAYS || '90');
|
||||
const cutoffDate = new Date();
|
||||
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
|
||||
|
||||
await prisma.videoView.deleteMany({
|
||||
where: { createdAt: { lt: cutoffDate } }
|
||||
});
|
||||
|
||||
await prisma.videoEvent.deleteMany({
|
||||
where: { createdAt: { lt: cutoffDate } }
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Add to `.env`:
|
||||
|
||||
```env
|
||||
# Video Analytics
|
||||
VIDEO_ANALYTICS_RETENTION_DAYS=90
|
||||
VIDEO_ANALYTICS_IP_HASHING_ENABLED=true
|
||||
|
||||
# Video Scheduling
|
||||
VIDEO_SCHEDULE_DEFAULT_TIMEZONE=UTC
|
||||
VIDEO_SCHEDULE_NOTIFICATION_ENABLED=true
|
||||
|
||||
# Preview Links
|
||||
VIDEO_PREVIEW_LINK_EXPIRY_HOURS=24
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Scheduled Publish Not Executing
|
||||
|
||||
**Check BullMQ Queue:**
|
||||
```bash
|
||||
# View queue status
|
||||
docker compose exec media-api npm run queue:status
|
||||
|
||||
# Retry failed jobs
|
||||
docker compose exec media-api npm run queue:retry
|
||||
```
|
||||
|
||||
**Check Redis Connection:**
|
||||
```bash
|
||||
docker compose logs redis
|
||||
docker compose exec redis redis-cli ping
|
||||
```
|
||||
|
||||
**Check Schedule History:**
|
||||
```sql
|
||||
SELECT * FROM video_schedule_history
|
||||
WHERE status = 'failed'
|
||||
ORDER BY scheduled_for DESC
|
||||
LIMIT 10;
|
||||
```
|
||||
|
||||
### Analytics Not Tracking
|
||||
|
||||
**Check Rate Limits:**
|
||||
```bash
|
||||
# View Redis rate limit keys
|
||||
docker compose exec redis redis-cli --scan --pattern "rl:*"
|
||||
|
||||
# Check remaining requests
|
||||
docker compose exec redis redis-cli GET "rl:track-view:192.168.1.100"
|
||||
```
|
||||
|
||||
**Check Network Tab:**
|
||||
- Open browser DevTools → Network
|
||||
- Filter by `/track/`
|
||||
- Verify 200 OK responses
|
||||
- Check for CORS errors
|
||||
|
||||
**Verify Tracking Endpoints:**
|
||||
```bash
|
||||
curl -X POST http://localhost:4100/api/track/view \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"videoId": 1}'
|
||||
```
|
||||
|
||||
### Preview Link Expired
|
||||
|
||||
**Regenerate Link:**
|
||||
1. Navigate to LibraryPage
|
||||
2. Hover over video card
|
||||
3. Click "More Actions" → "Generate Preview Link"
|
||||
4. New 24-hour link generated
|
||||
|
||||
**Adjust Expiry Time:**
|
||||
```env
|
||||
# In .env
|
||||
VIDEO_PREVIEW_LINK_EXPIRY_HOURS=48 # 2 days
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
**Coming Soon:**
|
||||
1. **Download Functionality** - Direct video file downloads
|
||||
2. **Thumbnail Generation** - Auto-generate thumbnails from video frames
|
||||
3. **Batch Event Submission** - `/track/batch` endpoint for bulk events
|
||||
4. **Scheduled Reports** - Email weekly analytics summaries
|
||||
5. **A/B Testing** - Compare multiple video versions
|
||||
6. **Heatmaps** - Visual representation of drop-off points
|
||||
7. **Export Analytics** - CSV/PDF export for reports
|
||||
8. **Custom Dashboards** - User-configurable analytics views
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
**Documentation:**
|
||||
- Main docs: `CLAUDE.md`
|
||||
- Analytics guide: `VIDEO_ANALYTICS_GUIDE.md`
|
||||
- API architecture: `api/src/modules/media/README.md`
|
||||
|
||||
**Issues:**
|
||||
Report bugs or request features at: https://github.com/anthropics/changemaker-lite/issues
|
||||
|
||||
**Questions:**
|
||||
Contact the development team or check the wiki for FAQs.
|
||||
@ -1,176 +0,0 @@
|
||||
# Nginx Domain Templating
|
||||
|
||||
## Overview
|
||||
|
||||
The nginx configuration now uses environment variable templating to support dynamic domain configuration. This allows you to change the domain for all services by simply updating the `DOMAIN` environment variable in `.env`.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Template Files**: Nginx configuration files are stored as templates with `.template` extension
|
||||
- `nginx/conf.d/api.conf.template`
|
||||
- `nginx/conf.d/default.conf.template`
|
||||
- `nginx/conf.d/services.conf.template`
|
||||
|
||||
2. **Environment Variable**: The `DOMAIN` variable in `.env` controls all subdomains
|
||||
```bash
|
||||
DOMAIN=betteredmonton.org
|
||||
```
|
||||
|
||||
3. **Startup Process**: When the nginx container starts:
|
||||
- The entrypoint script (`nginx/entrypoint.sh`) runs first
|
||||
- It uses `envsubst` to replace `${DOMAIN}` in templates with the actual value
|
||||
- Generated `.conf` files are created in `/etc/nginx/conf.d/`
|
||||
- Nginx starts with the generated configuration
|
||||
|
||||
4. **Docker Configuration**: The `DOMAIN` env var is passed to nginx via `docker-compose.yml`:
|
||||
```yaml
|
||||
environment:
|
||||
- DOMAIN=${DOMAIN:-cmlite.org}
|
||||
```
|
||||
|
||||
## Configured Subdomains
|
||||
|
||||
All subdomains automatically use the `DOMAIN` value from `.env`:
|
||||
|
||||
| Subdomain | Service | Port |
|
||||
|-----------|---------|------|
|
||||
| `${DOMAIN}` | Admin GUI (root domain) | - |
|
||||
| `app.${DOMAIN}` | Admin GUI | - |
|
||||
| `api.${DOMAIN}` | API Server | - |
|
||||
| `db.${DOMAIN}` | NocoDB | - |
|
||||
| `docs.${DOMAIN}` | MkDocs | - |
|
||||
| `code.${DOMAIN}` | Code Server | - |
|
||||
| `listmonk.${DOMAIN}` | Listmonk | - |
|
||||
| `grafana.${DOMAIN}` | Grafana | - |
|
||||
| `git.${DOMAIN}` | Gitea | - |
|
||||
| `n8n.${DOMAIN}` | n8n | - |
|
||||
| `mail.${DOMAIN}` | MailHog | - |
|
||||
| `qr.${DOMAIN}` | Mini QR | - |
|
||||
| `draw.${DOMAIN}` | Excalidraw | - |
|
||||
| `home.${DOMAIN}` | Homepage | - |
|
||||
|
||||
## Changing the Domain
|
||||
|
||||
To change to a new domain:
|
||||
|
||||
1. **Update `.env`**:
|
||||
```bash
|
||||
DOMAIN=newdomain.com
|
||||
```
|
||||
|
||||
2. **Rebuild nginx and restart admin**:
|
||||
```bash
|
||||
docker compose build nginx
|
||||
docker compose up -d nginx admin
|
||||
```
|
||||
|
||||
Note: Admin needs to restart to pick up the new DOMAIN for Vite's allowed hosts configuration.
|
||||
|
||||
3. **Update Pangolin resources** (if using Pangolin tunnel):
|
||||
- The Pangolin admin page will automatically show the new domain in the resource list
|
||||
- Create Public resources in Pangolin dashboard for each subdomain you need
|
||||
- Point each resource to `nginx:80` as the target
|
||||
|
||||
4. **Check nginx logs**:
|
||||
```bash
|
||||
docker compose logs nginx --tail 20
|
||||
```
|
||||
|
||||
You should see: `Configuring nginx for domain: newdomain.com`
|
||||
|
||||
## Pangolin Resource Creation
|
||||
|
||||
For each subdomain you want accessible through Pangolin:
|
||||
|
||||
1. Go to Pangolin dashboard → Resources → Create Resource
|
||||
2. **Resource Type**: Public Site
|
||||
3. **URL**: `https://subdomain.yourdomain.org`
|
||||
4. **Target**: `nginx` (or the Newt connection ID)
|
||||
5. **Protocol**: http
|
||||
6. **Backend**: `nginx:80`
|
||||
|
||||
Repeat for all required subdomains (app, api, db, etc.)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Duplicate server name warning**:
|
||||
- Check nginx logs for conflicting `server_name` directives
|
||||
- This usually means a subdomain is defined twice in the templates
|
||||
|
||||
**502 Bad Gateway**:
|
||||
- Verify the backend service is running: `docker compose ps`
|
||||
- Check nginx can reach the backend: `docker compose exec nginx wget -qO- http://backend:port`
|
||||
- Review nginx error logs: `docker compose logs nginx`
|
||||
|
||||
**Domain not updating**:
|
||||
- Ensure you rebuilt nginx: `docker compose build nginx`
|
||||
- Verify the DOMAIN env var is set: `docker compose config | grep DOMAIN`
|
||||
- Check generated configs: `docker compose exec nginx cat /etc/nginx/conf.d/services.conf | grep server_name`
|
||||
|
||||
## Technical Details
|
||||
|
||||
**Template Syntax**:
|
||||
```nginx
|
||||
server_name app.${DOMAIN};
|
||||
```
|
||||
|
||||
**Generated Output** (with `DOMAIN=betteredmonton.org`):
|
||||
```nginx
|
||||
server_name app.betteredmonton.org;
|
||||
```
|
||||
|
||||
**Entrypoint Script** (`nginx/entrypoint.sh`):
|
||||
```bash
|
||||
#!/bin/sh
|
||||
export DOMAIN=${DOMAIN:-cmlite.org}
|
||||
envsubst '${DOMAIN}' < /etc/nginx/conf.d/default.conf.template > /etc/nginx/conf.d/default.conf
|
||||
envsubst '${DOMAIN}' < /etc/nginx/conf.d/api.conf.template > /etc/nginx/conf.d/api.conf
|
||||
envsubst '${DOMAIN}' < /etc/nginx/conf.d/services.conf.template > /etc/nginx/conf.d/services.conf
|
||||
nginx -t
|
||||
exec /docker-entrypoint.sh "$@"
|
||||
```
|
||||
|
||||
## Files Modified
|
||||
|
||||
- `nginx/Dockerfile` — Added gettext package, entrypoint script
|
||||
- `nginx/entrypoint.sh` — New file: templates configs with envsubst
|
||||
- `nginx/conf.d/*.template` — Template versions of all nginx configs
|
||||
- `docker-compose.yml` — Added DOMAIN environment variable to nginx and admin services
|
||||
- `docker-compose.yml` — Removed read-only conf.d volume mount (configs generated at runtime)
|
||||
- `docker-compose.yml` — Updated healthcheck (removed crond check)
|
||||
- `admin/vite.config.ts` — Dynamic allowed hosts based on DOMAIN env var
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Single Source of Truth**: Change domain in one place (`.env`)
|
||||
2. **No Manual Edits**: No need to edit nginx configs or Vite config files
|
||||
3. **Environment-Specific**: Use different domains for dev/staging/production
|
||||
4. **Pangolin Integration**: Resource list automatically uses current domain
|
||||
5. **Version Control Friendly**: Templates are committed, generated configs are not
|
||||
6. **Vite Host Check**: Automatically allows the configured domain in Vite's dev server
|
||||
|
||||
## Vite Configuration
|
||||
|
||||
The admin Vite dev server now dynamically configures allowed hosts based on the DOMAIN environment variable:
|
||||
|
||||
```typescript
|
||||
// admin/vite.config.ts
|
||||
const domain = process.env.DOMAIN || 'cmlite.org';
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
allowedHosts: [
|
||||
`.${domain}`, // Allow all subdomains
|
||||
'changemaker-v2-admin', // Container hostname
|
||||
'localhost',
|
||||
'127.0.0.1',
|
||||
],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
This prevents Vite's host check from blocking requests when accessing the app through:
|
||||
- Pangolin tunnel with custom domain
|
||||
- Cloudflare tunnel
|
||||
- Any reverse proxy with custom domain
|
||||
- Docker container networking
|
||||
@ -1,689 +0,0 @@
|
||||
# Video Analytics Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This guide covers the setup, interpretation, and best practices for the Changemaker Lite video analytics system. The analytics platform tracks views, watch time, engagement metrics, and traffic sources while maintaining strong privacy protections.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Getting Started](#getting-started)
|
||||
2. [Understanding Metrics](#understanding-metrics)
|
||||
3. [Interpreting Data](#interpreting-data)
|
||||
4. [Privacy & Compliance](#privacy--compliance)
|
||||
5. [Best Practices](#best-practices)
|
||||
6. [Advanced Analytics](#advanced-analytics)
|
||||
|
||||
---
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Initial Setup
|
||||
|
||||
**1. Enable Analytics in Environment:**
|
||||
```env
|
||||
# .env
|
||||
VIDEO_ANALYTICS_RETENTION_DAYS=90
|
||||
VIDEO_ANALYTICS_IP_HASHING_ENABLED=true
|
||||
```
|
||||
|
||||
**2. Verify Tracking Endpoints:**
|
||||
```bash
|
||||
# Test view recording
|
||||
curl -X POST http://localhost:4100/api/track/view \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"videoId": 1, "referer": "https://example.com"}'
|
||||
|
||||
# Should return: {"viewId": 1}
|
||||
```
|
||||
|
||||
**3. Check Database Tables:**
|
||||
```sql
|
||||
-- Verify tables exist
|
||||
SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name LIKE 'video_%';
|
||||
|
||||
-- Should show:
|
||||
-- video_views
|
||||
-- video_events
|
||||
-- video_schedule_history
|
||||
```
|
||||
|
||||
### Accessing Analytics
|
||||
|
||||
**Quick Analytics (Video Card):**
|
||||
1. Navigate to `/app/media/library`
|
||||
2. Hover over any video card
|
||||
3. Click "Analytics" button (bar chart icon)
|
||||
4. View quick stats modal
|
||||
|
||||
**Detailed Analytics (Full Modal):**
|
||||
1. From quick analytics, click "View Detailed Analytics"
|
||||
2. Or press `A` keyboard shortcut while hovering
|
||||
3. Explore 3 tabs: Overview, Charts, Viewers
|
||||
|
||||
**Global Dashboard:**
|
||||
1. Navigate to `/app/media/analytics`
|
||||
2. View platform-wide statistics
|
||||
3. See top performing videos
|
||||
4. Switch between views/watch time metrics
|
||||
|
||||
---
|
||||
|
||||
## Understanding Metrics
|
||||
|
||||
### Core Metrics
|
||||
|
||||
#### 1. Total Views
|
||||
**Definition:** Number of times the video play button was clicked.
|
||||
|
||||
**What it measures:**
|
||||
- Initial engagement
|
||||
- Link/sharing effectiveness
|
||||
- Thumbnail appeal
|
||||
|
||||
**Good benchmark:**
|
||||
- New video: 10+ views in first 24h
|
||||
- Established content: 50+ views per week
|
||||
- Viral content: 500+ views in first week
|
||||
|
||||
**Not included:**
|
||||
- Page loads without play
|
||||
- Autoplay (if implemented)
|
||||
- Repeated views from same IP within 1 hour
|
||||
|
||||
#### 2. Unique Viewers
|
||||
**Definition:** Deduplicated viewers based on IP hash (anonymous) or user ID (registered).
|
||||
|
||||
**What it measures:**
|
||||
- Actual reach (not inflated by repeat views)
|
||||
- Audience size
|
||||
- Content discovery
|
||||
|
||||
**Calculation:**
|
||||
```sql
|
||||
-- Anonymous unique viewers (by IP hash)
|
||||
SELECT COUNT(DISTINCT ip_address) FROM video_views
|
||||
WHERE video_id = ? AND user_id IS NULL;
|
||||
|
||||
-- Registered unique viewers
|
||||
SELECT COUNT(DISTINCT user_id) FROM video_views
|
||||
WHERE video_id = ? AND user_id IS NOT NULL;
|
||||
```
|
||||
|
||||
**Typical ratios:**
|
||||
- Unique viewers / Total views: 60-80% (healthy)
|
||||
- Below 50%: High repeat viewing (engagement or confusion?)
|
||||
- Above 90%: Limited repeat engagement
|
||||
|
||||
#### 3. Average Watch Time
|
||||
**Definition:** Mean duration viewers spent watching the video.
|
||||
|
||||
**What it measures:**
|
||||
- Content quality
|
||||
- Viewer interest retention
|
||||
- Optimal video length validation
|
||||
|
||||
**Formula:**
|
||||
```
|
||||
Average Watch Time = Total Watch Time Seconds / Total Views
|
||||
```
|
||||
|
||||
**Benchmarks by video length:**
|
||||
- Short (< 2 min): 70%+ of duration
|
||||
- Medium (2-10 min): 50%+ of duration
|
||||
- Long (> 10 min): 30%+ of duration
|
||||
|
||||
**Red flags:**
|
||||
- Average < 30s on 5-min video: Poor hook
|
||||
- Average < 10% of duration: Wrong audience targeting
|
||||
- Average > 95%: Possible bot traffic
|
||||
|
||||
#### 4. Completion Rate
|
||||
**Definition:** Percentage of viewers who watched ≥95% of the video.
|
||||
|
||||
**What it measures:**
|
||||
- Content value through to end
|
||||
- Call-to-action effectiveness
|
||||
- Audience match
|
||||
|
||||
**Formula:**
|
||||
```
|
||||
Completion Rate = (Completed Views / Total Views) × 100
|
||||
```
|
||||
|
||||
**Industry benchmarks:**
|
||||
- Educational content: 40-60%
|
||||
- Entertainment: 30-50%
|
||||
- Promotional: 20-40%
|
||||
- Tutorial/How-to: 50-70%
|
||||
|
||||
**Completion threshold:** 95% (configurable in code)
|
||||
|
||||
#### 5. Total Watch Time
|
||||
**Definition:** Cumulative seconds all viewers spent watching.
|
||||
|
||||
**What it measures:**
|
||||
- Overall engagement
|
||||
- Platform value (YouTube-style metric)
|
||||
- Content ROI
|
||||
|
||||
**Use cases:**
|
||||
- Compare videos of different lengths fairly
|
||||
- Calculate "watch hours" for reporting
|
||||
- Prioritize content for promotion
|
||||
|
||||
**Example:**
|
||||
- Video A: 100 views, 2 min avg = 200 min total
|
||||
- Video B: 50 views, 5 min avg = 250 min total
|
||||
- **Video B has higher total watch time (better engagement)**
|
||||
|
||||
---
|
||||
|
||||
## Interpreting Data
|
||||
|
||||
### Analytics Dashboard Tabs
|
||||
|
||||
#### Overview Tab
|
||||
|
||||
**Stat Cards:**
|
||||
- **Total Views:** Overall reach indicator
|
||||
- **Unique Viewers:** True audience size
|
||||
- **Avg Watch Time:** Engagement quality
|
||||
- **Completion Rate:** Content effectiveness
|
||||
|
||||
**Total Watch Time Card:**
|
||||
- Displayed in hours/minutes format
|
||||
- Secondary display shows raw seconds
|
||||
- Use for comparing videos of different lengths
|
||||
|
||||
**Top Referrers Table:**
|
||||
- Shows traffic sources (domains)
|
||||
- Sortable by view count
|
||||
- Identifies effective promotion channels
|
||||
- "Direct" = no referrer (bookmarks, direct links)
|
||||
|
||||
**Interpreting referrers:**
|
||||
```
|
||||
google.com (45 views) → SEO working well
|
||||
facebook.com (23 views) → Social sharing successful
|
||||
example.com (12 views) → Partner site traffic
|
||||
(direct) (67 views) → Email links or bookmarks
|
||||
```
|
||||
|
||||
#### Charts Tab
|
||||
|
||||
**Views Over Time (Area Chart):**
|
||||
- Last 30 days by default
|
||||
- Identifies trends and spikes
|
||||
- Helps correlate with marketing campaigns
|
||||
|
||||
**Patterns to look for:**
|
||||
- **Steady climb:** Organic growth, good SEO
|
||||
- **Spike then drop:** Campaign effect, needs sustained promotion
|
||||
- **Plateau:** Market saturation, needs refresh
|
||||
- **Decline:** Algorithm change, content aging
|
||||
|
||||
**Traffic Sources Distribution (Pie Chart):**
|
||||
- Visual breakdown of referrer sources
|
||||
- Quickly identify dominant channels
|
||||
- Guide marketing budget allocation
|
||||
|
||||
**Example interpretation:**
|
||||
```
|
||||
70% Direct → Email campaign working well
|
||||
15% Social → Increase social promotion
|
||||
10% Search → Improve SEO
|
||||
5% Other → Diversify sources
|
||||
```
|
||||
|
||||
#### Viewers Tab
|
||||
|
||||
**Registered Viewers Table:**
|
||||
- Shows logged-in users who watched
|
||||
- Columns: User, Email, Watch Time, Status
|
||||
- Filter by "Completed" vs "Partial"
|
||||
- Sort by watch time
|
||||
|
||||
**Use cases:**
|
||||
1. **Follow-up emails** to partial viewers
|
||||
2. **Identify super fans** (high completion)
|
||||
3. **A/B test messaging** based on completion
|
||||
4. **Segment audiences** for future content
|
||||
|
||||
**Privacy note:** Only shows registered users who consented to tracking.
|
||||
|
||||
### Global Analytics Dashboard
|
||||
|
||||
**Platform Statistics:**
|
||||
- **Total Videos:** Content library size
|
||||
- **Total Views:** Platform reach
|
||||
- **Total Watch Time:** Cumulative engagement
|
||||
- **Avg Completion Rate:** Platform-wide quality metric
|
||||
|
||||
**Calculated Averages:**
|
||||
- **Avg Views per Video:** total views ÷ total videos
|
||||
- **Avg Watch Time per Video:** total watch time ÷ total videos
|
||||
|
||||
**Top Videos Table:**
|
||||
- Switchable metric (Views or Watch Time)
|
||||
- Rank column (🥇🥈🥉 for top 3)
|
||||
- Helps identify best performers
|
||||
- Guide content strategy
|
||||
|
||||
---
|
||||
|
||||
## Privacy & Compliance
|
||||
|
||||
### GDPR Compliance
|
||||
|
||||
**Article 6 (Lawful Basis):**
|
||||
- **Anonymous users:** Legitimate interest (analytics)
|
||||
- **Registered users:** Explicit consent required
|
||||
|
||||
**Article 17 (Right to be Forgotten):**
|
||||
- Users can request analytics deletion
|
||||
- Admin can reset analytics via "Reset Analytics" button
|
||||
- Deletes all views, events, and computed stats
|
||||
|
||||
**Article 13 (Transparency):**
|
||||
- Disclose tracking in privacy policy
|
||||
- Explain what data is collected
|
||||
- How data is used (analytics only)
|
||||
|
||||
### Data Minimization
|
||||
|
||||
**What we collect:**
|
||||
✅ IP address (SHA-256 hashed)
|
||||
✅ User agent (truncated)
|
||||
✅ Referrer URL
|
||||
✅ Watch time (seconds)
|
||||
✅ Video events (play, pause, seek, complete)
|
||||
|
||||
**What we don't collect:**
|
||||
❌ Exact geolocation
|
||||
❌ Device fingerprints
|
||||
❌ Cross-site tracking cookies
|
||||
❌ Personally identifiable information (PII) for anonymous users
|
||||
|
||||
### Retention Policy
|
||||
|
||||
**Default Settings:**
|
||||
```env
|
||||
VIDEO_ANALYTICS_RETENTION_DAYS=90
|
||||
```
|
||||
|
||||
**Retention schedule:**
|
||||
- **0-90 days:** Full data retained
|
||||
- **90+ days:** Automatically deleted (nightly job)
|
||||
|
||||
**Configuring retention:**
|
||||
```env
|
||||
# Short-term (30 days)
|
||||
VIDEO_ANALYTICS_RETENTION_DAYS=30
|
||||
|
||||
# Long-term (1 year)
|
||||
VIDEO_ANALYTICS_RETENTION_DAYS=365
|
||||
|
||||
# Indefinite (not recommended)
|
||||
VIDEO_ANALYTICS_RETENTION_DAYS=0
|
||||
```
|
||||
|
||||
### IP Address Hashing
|
||||
|
||||
**How it works:**
|
||||
```typescript
|
||||
// SHA-256 hash before storage
|
||||
const ipHash = crypto
|
||||
.createHash('sha256')
|
||||
.update(ipAddress)
|
||||
.digest('hex');
|
||||
|
||||
// Original: 192.168.1.100
|
||||
// Stored: a591a6d40bf420404a011733cfb7b190d62c65bf0bcda32b57b277d9ad9f146e
|
||||
```
|
||||
|
||||
**Benefits:**
|
||||
- **One-way:** Cannot reverse to original IP
|
||||
- **Consistent:** Same IP = same hash (deduplication works)
|
||||
- **Secure:** Prevents IP exposure in data breaches
|
||||
|
||||
**Limitations:**
|
||||
- Cannot geolocate (by design)
|
||||
- Cannot block specific IPs retroactively
|
||||
- Rainbow table attacks possible (but impractical)
|
||||
|
||||
### User Consent
|
||||
|
||||
**Best practices:**
|
||||
1. **Cookie banner:** Disclose analytics tracking
|
||||
2. **Privacy policy:** Link to detailed data usage
|
||||
3. **Opt-out option:** Allow users to disable tracking
|
||||
4. **Clear language:** No legal jargon
|
||||
|
||||
**Example consent text:**
|
||||
```
|
||||
We use analytics to understand how our videos perform.
|
||||
We collect anonymized view data (IP hash, watch time, referrer).
|
||||
For registered users, we track which videos you watch to improve recommendations.
|
||||
You can opt out in your account settings.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Analyzing Performance
|
||||
|
||||
**1. Compare Similar Videos**
|
||||
Don't compare short vs long videos using view count alone.
|
||||
|
||||
**Good comparison:**
|
||||
```
|
||||
Video A (2 min): 100 views, 80% completion
|
||||
Video B (2 min): 120 views, 65% completion
|
||||
→ Video A is better (higher completion despite fewer views)
|
||||
```
|
||||
|
||||
**Bad comparison:**
|
||||
```
|
||||
Video A (2 min): 100 views
|
||||
Video B (10 min): 80 views
|
||||
→ Can't conclude which is better without watch time/completion
|
||||
```
|
||||
|
||||
**2. Track Trends, Not Absolutes**
|
||||
Focus on improvement over time, not hitting arbitrary numbers.
|
||||
|
||||
**Example trend analysis:**
|
||||
```
|
||||
Month 1: 50 avg views/video, 40% completion
|
||||
Month 2: 65 avg views/video, 45% completion
|
||||
Month 3: 75 avg views/video, 50% completion
|
||||
→ Positive trend! Content quality improving.
|
||||
```
|
||||
|
||||
**3. Identify Drop-off Points**
|
||||
Use the VideoEvent table to find where viewers quit.
|
||||
|
||||
**SQL Query:**
|
||||
```sql
|
||||
-- Find common seek/pause points
|
||||
SELECT
|
||||
FLOOR(timestamp / 30) * 30 AS time_bucket,
|
||||
COUNT(*) AS event_count,
|
||||
event_type
|
||||
FROM video_events
|
||||
WHERE video_id = ? AND event_type IN ('pause', 'seek')
|
||||
GROUP BY time_bucket, event_type
|
||||
ORDER BY time_bucket;
|
||||
```
|
||||
|
||||
**Interpretation:**
|
||||
- High pause rate at 2:30 → Boring section?
|
||||
- Many seeks past 1:00 → Intro too long?
|
||||
- Seeks to end → Looking for conclusion?
|
||||
|
||||
### Improving Metrics
|
||||
|
||||
**Increase Views:**
|
||||
1. **Better thumbnails** - A/B test visual appeal
|
||||
2. **Compelling titles** - Use action verbs, questions
|
||||
3. **SEO optimization** - Keywords in title/description
|
||||
4. **Social promotion** - Share on relevant platforms
|
||||
5. **Email campaigns** - Send to engaged subscribers
|
||||
|
||||
**Increase Watch Time:**
|
||||
1. **Strong hook** - First 10 seconds are critical
|
||||
2. **Clear structure** - Tell viewers what to expect
|
||||
3. **Pacing** - Remove slow sections
|
||||
4. **Visual variety** - Change scenes, graphics
|
||||
5. **Audio quality** - Poor audio = instant exit
|
||||
|
||||
**Increase Completion Rate:**
|
||||
1. **Deliver value early** - Don't save best for last
|
||||
2. **Match length to content** - Shorter often better
|
||||
3. **Call to action** - Give reason to watch to end
|
||||
4. **End screens** - Tease next video
|
||||
5. **Remove fluff** - Every second must add value
|
||||
|
||||
### A/B Testing
|
||||
|
||||
**Test variables one at a time:**
|
||||
1. **Thumbnails** - Same video, different thumbnail
|
||||
2. **Titles** - "How to X" vs "X Explained"
|
||||
3. **Length** - 5-min vs 10-min version
|
||||
4. **Format** - Tutorial vs case study
|
||||
|
||||
**Minimum test duration:** 1 week or 100 views
|
||||
|
||||
**Statistical significance:**
|
||||
Use chi-square test for completion rates:
|
||||
```
|
||||
Video A: 50/100 completed (50%)
|
||||
Video B: 60/100 completed (60%)
|
||||
→ 10% improvement, test for significance
|
||||
```
|
||||
|
||||
### Reporting
|
||||
|
||||
**Weekly Report Template:**
|
||||
```markdown
|
||||
# Video Performance Report - Week of [Date]
|
||||
|
||||
## Top Performers
|
||||
1. [Video Title] - 500 views, 65% completion
|
||||
2. [Video Title] - 400 views, 70% completion
|
||||
3. [Video Title] - 350 views, 55% completion
|
||||
|
||||
## Platform Metrics
|
||||
- Total Views: 2,500 (+15% vs last week)
|
||||
- Avg Watch Time: 3:45 (+10s vs last week)
|
||||
- Avg Completion: 58% (+3% vs last week)
|
||||
|
||||
## Traffic Sources
|
||||
- Direct: 45%
|
||||
- Social: 30%
|
||||
- Search: 15%
|
||||
- Other: 10%
|
||||
|
||||
## Action Items
|
||||
- [ ] Improve thumbnail for [Low Performer]
|
||||
- [ ] Create follow-up email for partial viewers
|
||||
- [ ] Double down on Facebook promotion (highest completion)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Advanced Analytics
|
||||
|
||||
### Custom Queries
|
||||
|
||||
**Find your best time to publish:**
|
||||
```sql
|
||||
SELECT
|
||||
EXTRACT(DOW FROM created_at) AS day_of_week,
|
||||
EXTRACT(HOUR FROM created_at) AS hour_of_day,
|
||||
COUNT(*) AS view_count,
|
||||
AVG(watch_time_seconds) AS avg_watch_time
|
||||
FROM video_views
|
||||
GROUP BY day_of_week, hour_of_day
|
||||
ORDER BY view_count DESC
|
||||
LIMIT 10;
|
||||
```
|
||||
|
||||
**Identify super fans (registered users):**
|
||||
```sql
|
||||
SELECT
|
||||
u.name,
|
||||
u.email,
|
||||
COUNT(DISTINCT vv.video_id) AS videos_watched,
|
||||
SUM(vv.watch_time_seconds) AS total_watch_time,
|
||||
AVG(vv.watch_time_seconds) AS avg_watch_time
|
||||
FROM video_views vv
|
||||
JOIN users u ON vv.user_id = u.id
|
||||
GROUP BY u.id, u.name, u.email
|
||||
HAVING COUNT(DISTINCT vv.video_id) >= 5
|
||||
ORDER BY total_watch_time DESC
|
||||
LIMIT 20;
|
||||
```
|
||||
|
||||
**Calculate retention curve:**
|
||||
```sql
|
||||
WITH time_buckets AS (
|
||||
SELECT
|
||||
video_id,
|
||||
view_id,
|
||||
FLOOR(timestamp / 10) * 10 AS time_bucket
|
||||
FROM video_events
|
||||
WHERE video_id = ? AND event_type = 'complete'
|
||||
)
|
||||
SELECT
|
||||
time_bucket,
|
||||
COUNT(DISTINCT view_id) AS viewers_at_time,
|
||||
(COUNT(DISTINCT view_id) * 100.0 / (
|
||||
SELECT COUNT(DISTINCT id) FROM video_views WHERE video_id = ?
|
||||
)) AS retention_percentage
|
||||
FROM time_buckets
|
||||
GROUP BY time_bucket
|
||||
ORDER BY time_bucket;
|
||||
```
|
||||
|
||||
### Integrations
|
||||
|
||||
**Google Analytics (Future):**
|
||||
- Send video events to GA4
|
||||
- Track conversions from video views
|
||||
- Cross-reference with site analytics
|
||||
|
||||
**Email Marketing (Future):**
|
||||
- Segment users by completion rate
|
||||
- Send follow-up emails to partial viewers
|
||||
- Recommend similar videos based on watch history
|
||||
|
||||
**CRM Integration (Future):**
|
||||
- Sync super fans to CRM
|
||||
- Track video engagement per lead
|
||||
- Score leads based on video views
|
||||
|
||||
### Machine Learning Opportunities
|
||||
|
||||
**Recommendation Engine:**
|
||||
- Collaborative filtering (users who watched X also watched Y)
|
||||
- Content-based (similar titles, producers, duration)
|
||||
- Hybrid approach
|
||||
|
||||
**Predictive Analytics:**
|
||||
- Predict video performance before publishing
|
||||
- Forecast future views based on trends
|
||||
- Identify optimal video length per category
|
||||
|
||||
**Anomaly Detection:**
|
||||
- Flag unusual spike in views (bot traffic?)
|
||||
- Detect sudden drop in completion (video broken?)
|
||||
- Alert on referrer spam
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Low View Count
|
||||
|
||||
**Diagnosis:**
|
||||
1. Check if video is published (`isPublished = true`)
|
||||
2. Verify video is not scheduled for future (`scheduledPublishAt`)
|
||||
3. Review sharing/promotion efforts
|
||||
4. Check if thumbnail is loading
|
||||
|
||||
**Solutions:**
|
||||
- Publish immediately if scheduled too far out
|
||||
- Generate and share preview link
|
||||
- Post to social media
|
||||
- Add to email newsletter
|
||||
|
||||
### Low Completion Rate
|
||||
|
||||
**Diagnosis:**
|
||||
1. Watch video yourself critically
|
||||
2. Check drop-off points (VideoEvent table)
|
||||
3. Review average watch time vs duration
|
||||
4. Compare to similar videos
|
||||
|
||||
**Solutions:**
|
||||
- Trim intro (if drop-off < 30s)
|
||||
- Add chapters/timestamps (if drop-off mid-video)
|
||||
- Improve pacing (if gradual decline)
|
||||
- Ensure audio quality (if sharp drop-off)
|
||||
|
||||
### Tracking Not Working
|
||||
|
||||
**Check list:**
|
||||
1. ✅ Network tab shows 200 OK responses
|
||||
2. ✅ Rate limits not exceeded (Redis keys)
|
||||
3. ✅ No CORS errors in console
|
||||
4. ✅ videoRef.current exists before tracking
|
||||
5. ✅ Heartbeat interval running (check console logs)
|
||||
|
||||
**Debug mode:**
|
||||
```typescript
|
||||
// Add to VideoViewerModal.tsx
|
||||
console.log('Recording view:', { videoId: video.id });
|
||||
console.log('Heartbeat sent:', { viewId, watchTimeSeconds });
|
||||
```
|
||||
|
||||
### Inaccurate Metrics
|
||||
|
||||
**Common causes:**
|
||||
1. **Bot traffic** - High views, low watch time
|
||||
2. **Autoplayers** - Views without intent
|
||||
3. **Video loops** - Same user, repeated views
|
||||
4. **Test data** - QA testing inflating numbers
|
||||
|
||||
**Solutions:**
|
||||
- Filter views with < 5s watch time
|
||||
- Exclude internal IPs (if known)
|
||||
- Reset analytics for test videos
|
||||
- Use unique viewers metric instead of total views
|
||||
|
||||
---
|
||||
|
||||
## Future Roadmap
|
||||
|
||||
**Q2 2026:**
|
||||
- Heatmap visualization (drop-off points)
|
||||
- Exported reports (CSV, PDF)
|
||||
- Email digests (weekly summaries)
|
||||
|
||||
**Q3 2026:**
|
||||
- Recommendation engine
|
||||
- A/B testing framework
|
||||
- Advanced segmentation
|
||||
|
||||
**Q4 2026:**
|
||||
- Predictive analytics
|
||||
- ML-based insights
|
||||
- Real-time dashboards
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
**Documentation:**
|
||||
- [Media Admin Features Guide](./MEDIA_ADMIN_FEATURES.md)
|
||||
- [API Documentation](../api/src/modules/media/README.md)
|
||||
- [CLAUDE.md](../CLAUDE.md) - Project overview
|
||||
|
||||
**External Resources:**
|
||||
- [YouTube Analytics Best Practices](https://support.google.com/youtube/answer/1714323)
|
||||
- [Wistia Video Marketing Guide](https://wistia.com/learn/marketing)
|
||||
- [Vimeo Analytics Documentation](https://vimeo.com/blog/post/video-analytics-guide)
|
||||
|
||||
**Support:**
|
||||
- GitHub Issues: https://github.com/anthropics/changemaker-lite/issues
|
||||
- Wiki: https://wiki.changemaker-lite.org
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** February 2026
|
||||
**Version:** 1.0
|
||||
**Author:** Changemaker Lite Development Team
|
||||
@ -1,118 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Fix Container Directory Permissions
|
||||
# Run this script if you encounter EACCES (permission denied) errors
|
||||
# when starting Docker containers.
|
||||
|
||||
cat << "EOF"
|
||||
╔═══════════════════════════════════════════════════╗
|
||||
║ Changemaker - Fix Container Permissions ║
|
||||
╚═══════════════════════════════════════════════════╝
|
||||
EOF
|
||||
|
||||
# Get the absolute path of the script directory
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
|
||||
# Get the user/group IDs from .env or use defaults
|
||||
if [ -f "$SCRIPT_DIR/.env" ]; then
|
||||
source <(grep -E '^(USER_ID|GROUP_ID)=' "$SCRIPT_DIR/.env")
|
||||
fi
|
||||
USER_ID=${USER_ID:-1000}
|
||||
GROUP_ID=${GROUP_ID:-1000}
|
||||
|
||||
echo ""
|
||||
echo "Using UID: $USER_ID, GID: $GROUP_ID"
|
||||
echo ""
|
||||
|
||||
# Define directories that need to be writable by containers
|
||||
declare -A writable_dirs=(
|
||||
["$SCRIPT_DIR/configs/code-server/.config"]="Code Server config"
|
||||
["$SCRIPT_DIR/configs/code-server/.local"]="Code Server local data"
|
||||
["$SCRIPT_DIR/mkdocs/.cache"]="MkDocs cache (social cards, etc.)"
|
||||
["$SCRIPT_DIR/mkdocs/site"]="MkDocs built site"
|
||||
["$SCRIPT_DIR/assets/uploads"]="Listmonk uploads"
|
||||
["$SCRIPT_DIR/assets/images"]="Shared images"
|
||||
["$SCRIPT_DIR/configs/homepage/logs"]="Homepage logs"
|
||||
)
|
||||
|
||||
errors=0
|
||||
fixed=0
|
||||
needs_sudo=()
|
||||
|
||||
echo "Checking and fixing directory permissions..."
|
||||
echo ""
|
||||
|
||||
for dir_path in "${!writable_dirs[@]}"; do
|
||||
dir_desc="${writable_dirs[$dir_path]}"
|
||||
|
||||
# Create directory if it doesn't exist
|
||||
if [ ! -d "$dir_path" ]; then
|
||||
echo " Creating: $dir_path"
|
||||
mkdir -p "$dir_path"
|
||||
fi
|
||||
|
||||
# Add .gitkeep to track empty directories in git
|
||||
if [ ! -f "$dir_path/.gitkeep" ]; then
|
||||
touch "$dir_path/.gitkeep" 2>/dev/null
|
||||
fi
|
||||
|
||||
# Try to fix permissions
|
||||
if chmod -R 777 "$dir_path" 2>/dev/null; then
|
||||
echo " ✅ $dir_desc"
|
||||
((fixed++))
|
||||
else
|
||||
echo " ⚠️ $dir_desc - needs sudo"
|
||||
needs_sudo+=("$dir_path")
|
||||
((errors++))
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
|
||||
# If there are directories that need sudo, offer to fix them
|
||||
if [ ${#needs_sudo[@]} -gt 0 ]; then
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "Some directories need elevated permissions to fix."
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
|
||||
read -p "Would you like to fix them with sudo? [Y/n]: " use_sudo
|
||||
|
||||
if [[ ! "$use_sudo" =~ ^[Nn]$ ]]; then
|
||||
echo ""
|
||||
for dir_path in "${needs_sudo[@]}"; do
|
||||
dir_desc="${writable_dirs[$dir_path]}"
|
||||
echo " Fixing: $dir_desc"
|
||||
|
||||
# First try to change ownership, then permissions
|
||||
if sudo chown -R "$USER_ID:$GROUP_ID" "$dir_path" 2>/dev/null && \
|
||||
sudo chmod -R 777 "$dir_path" 2>/dev/null; then
|
||||
echo " ✅ Fixed: $dir_desc"
|
||||
((fixed++))
|
||||
((errors--))
|
||||
else
|
||||
echo " ❌ Failed: $dir_desc"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "Summary"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo " Fixed: $fixed directories"
|
||||
if [ $errors -gt 0 ]; then
|
||||
echo " Errors: $errors directories still need attention"
|
||||
echo ""
|
||||
echo "To manually fix remaining issues, run:"
|
||||
echo " sudo chown -R $USER_ID:$GROUP_ID $SCRIPT_DIR"
|
||||
echo " sudo chmod -R 755 $SCRIPT_DIR"
|
||||
exit 1
|
||||
else
|
||||
echo ""
|
||||
echo "✅ All container directories are properly configured!"
|
||||
echo ""
|
||||
echo "You can now start your containers with:"
|
||||
echo " docker compose up -d"
|
||||
fi
|
||||
@ -1,271 +0,0 @@
|
||||
---
|
||||
title: "API Reference | Represent Elected Officials and Electoral Districts API for Canada"
|
||||
source: "https://represent.opennorth.ca/api/"
|
||||
author:
|
||||
published:
|
||||
created: 2025-09-17
|
||||
description: "Find the elected officials and electoral districts for any Canadian address or postal code, at all levels of government"
|
||||
tags:
|
||||
- "clippings"
|
||||
---
|
||||
### Endpoints
|
||||
|
||||
The base URL of all endpoints is `https://represent.opennorth.ca`. All endpoints output JSON.
|
||||
|
||||
- [Postal codes](https://represent.opennorth.ca/api/#postcode)
|
||||
- [Boundary sets](https://represent.opennorth.ca/api/#boundaryset)
|
||||
- [Boundaries](https://represent.opennorth.ca/api/#boundary)
|
||||
- [Representative sets](https://represent.opennorth.ca/api/#representativeset)
|
||||
- [Representatives](https://represent.opennorth.ca/api/#representative)
|
||||
- [Elections](https://represent.opennorth.ca/api/#election)
|
||||
- [Candidates](https://represent.opennorth.ca/api/#candidate)
|
||||
|
||||
### Paginate
|
||||
|
||||
Results are paginated 20 per page by default. Set the number of results per page by adding a `limit` query parameter. Change pages using the `offset` query parameter or using the `next` and `previous` links under the `meta` field in the response to navigate to the next and previous pages (if any). Under the `meta` field, `total_count` is the number of results.
|
||||
|
||||
### Filter results
|
||||
|
||||
Filter results with query parameters. Each endpoint below lists the fields on which you can filter results. To filter for representatives whose first name is “Rodney”, for example, request `/representatives/?first_name=Rodney`. To filter for MPs whose first name is "Rodney", request `/representatives/house-of-commons/?last_name=Rodney`.
|
||||
|
||||
Perform substring searches by appending `__querytype` to the parameter name, where `querytype` is one of `iexact`, `contains`, `icontains`, `startswith`, `istartswith`, `endswith`, `iendswith` or `isnull`. A leading `i` makes the match case-insensitive. For example, to find representatives whose last name starts with “M” or “m”, request `/representatives/?last_name__istartswith=m`.
|
||||
|
||||
### Download in bulk
|
||||
|
||||
To download all representatives, send a request to [https://represent.opennorth.ca/representatives/?limit=1000](https://represent.opennorth.ca/representatives/?limit=1000) and follow the `next` link under the `meta` field until you reach the end. We host the shapefiles and postal code concordances on [GitHub](https://github.com/opennorth/represent-canada-data).
|
||||
|
||||
### Rate limits
|
||||
|
||||
Represent is free up to 60 requests per minute (86,400 queries/day). If you need to make more queries, [contact us](https://represent.opennorth.ca/api/); otherwise, you may get HTTP 503 errors.
|
||||
|
||||
### Debugging
|
||||
|
||||
For a browsable, HTML version of the JSON response, add a `format=apibrowser` query parameter. Add `pretty=1` to just indent the raw JSON.
|
||||
|
||||
### JSONP
|
||||
|
||||
We support JSONP for client-side cross-domain requests – just add a `callback` query parameter.
|
||||
|
||||
### Libraries
|
||||
|
||||
- [Drupal](https://drupal.org/project/represent)
|
||||
- [WordPress](https://wordpress.org/plugins/represent-api/)
|
||||
- [Ruby](https://github.com/opennorth/govkit-ca#readme)
|
||||
- [Ruby](https://github.com/cpb/opennorth-represent#readme) by Caleb Buxton
|
||||
- [Python](https://github.com/ncadou/pyrepresent#readme) by Nicolas Cadou
|
||||
- [Node.js](https://github.com/sprice/represent#readme) by Shawn Price
|
||||
- [CiviCRM](https://drupal.org/project/civinorth) by Alan Dixon
|
||||
|
||||
[Privacy policy](https://represent.opennorth.ca/privacy/)
|
||||
|
||||
Find representatives and boundaries by postal code.
|
||||
|
||||
To see what boundary sets and representative sets are available, consult the [boundary sets](https://represent.opennorth.ca/api/#boundaryset) and [representative sets](https://represent.opennorth.ca/api/#representativeset) endpoints. Are we missing information that you need? [Contact us](https://represent.opennorth.ca/api/) so that we can make it a priority.
|
||||
|
||||
### Request
|
||||
|
||||
URLs must include the postal code in uppercase letters with no spaces.
|
||||
|
||||
### Response
|
||||
|
||||
The `boundaries_centroid` field lists boundaries that contain the postal code’s center point (centroid). A centroid is a point, but a postal code can be a line or polygon, so the list of boundaries in `boundaries_centroid` **will sometimes be inaccurate**.
|
||||
|
||||
The `boundaries_concordance` field lists boundaries linked to the postal code according to official government data. Postal codes can cross boundaries, therefore `boundaries_concordance` may list many Ontario provincial districts for a postal code like K0A 1K0.
|
||||
|
||||
The `representatives_centroid` and `representatives_concordance` fields behave similarly.
|
||||
|
||||
In most cases, the `city`, `province` and `centroid` fields will be non-empty.
|
||||
|
||||
Find representatives and boundaries by postal code
|
||||
|
||||
[/postcodes/L5G4L3/](https://represent.opennorth.ca/postcodes/L5G4L3/?format=apibrowser) Click to view JSON
|
||||
|
||||
Find representatives and boundaries by postal code, limiting results to a specific boundary set
|
||||
|
||||
[/postcodes/L5G4L3/?sets=federal-electoral-districts](https://represent.opennorth.ca/postcodes/L5G4L3/?sets=federal-electoral-districts&format=apibrowser)
|
||||
|
||||
To see what boundary sets are available, consult the [boundary sets](https://represent.opennorth.ca/api/#boundaryset) endpoint.
|
||||
|
||||
A boundary set is a group of electoral districts, like BC provincial districts or Toronto wards.
|
||||
|
||||
Do we not have a set of boundaries that you need? [Contact us](https://represent.opennorth.ca/api/) so that we can make it a priority.
|
||||
|
||||
Get one page of boundary sets
|
||||
|
||||
[/boundary-sets/](https://represent.opennorth.ca/boundary-sets/?format=apibrowser) Click to view JSON
|
||||
|
||||
Get one boundary set
|
||||
|
||||
[/boundary-sets/federal-electoral-districts/](https://represent.opennorth.ca/boundary-sets/federal-electoral-districts/?format=apibrowser)
|
||||
|
||||
Filter boundary sets by `name` or `domain`
|
||||
|
||||
[/boundary-sets/?domain=Canada](https://represent.opennorth.ca/boundary-sets/?domain=Canada&format=apibrowser)
|
||||
|
||||
The response's `external_id` field (not always present) is the boundary's machine identifier. The `metadata` field contains all attributes from the source shapefile; it is unmodified and may be out-of-date or erroneous.
|
||||
|
||||
Get one page of boundaries
|
||||
|
||||
[/boundaries/](https://represent.opennorth.ca/boundaries/?format=apibrowser) Click to view JSON
|
||||
|
||||
Get one page of boundaries from a boundary set
|
||||
|
||||
[/boundaries/toronto-wards-2018/](https://represent.opennorth.ca/boundaries/toronto-wards-2018/?format=apibrowser)
|
||||
|
||||
To see what boundary sets are available, consult the [boundary sets](https://represent.opennorth.ca/api/#boundaryset) endpoint.
|
||||
|
||||
Get one page of boundaries from multiple boundary sets (comma-separated)
|
||||
|
||||
[/boundaries/?sets=toronto-wards-2018,ottawa-wards](https://represent.opennorth.ca/boundaries/?sets=toronto-wards-2018,ottawa-wards&format=apibrowser)
|
||||
|
||||
To see what boundary sets are available, consult the [boundary sets](https://represent.opennorth.ca/api/#boundaryset) endpoint.
|
||||
|
||||
Get one boundary
|
||||
|
||||
[/boundaries/nova-scotia-electoral-districts-2019/halifax-atlantic/](https://represent.opennorth.ca/boundaries/nova-scotia-electoral-districts-2019/halifax-atlantic/?format=apibrowser)
|
||||
|
||||
Filter all boundaries by `name` or `external_id`
|
||||
|
||||
[/boundaries/?name=Niagara Falls](https://represent.opennorth.ca/boundaries/?name=Niagara%20Falls&format=apibrowser)
|
||||
|
||||
Filter a boundary set's boundaries by `name` or `external_id`
|
||||
|
||||
[/boundaries/census-subdivisions/?name=Niagara Falls](https://represent.opennorth.ca/boundaries/census-subdivisions/?name=Niagara%20Falls&format=apibrowser)
|
||||
|
||||
To see what boundary sets are available, consult the [boundary sets](https://represent.opennorth.ca/api/#boundaryset) endpoint.
|
||||
|
||||
### Geospatial queries
|
||||
|
||||
Find all boundaries by latitude and longitude
|
||||
|
||||
[/boundaries/?contains=45.524,-73.596](https://represent.opennorth.ca/boundaries/?contains=45.524,-73.596&format=apibrowser)
|
||||
|
||||
Find a boundary set's boundaries by latitude and longitude
|
||||
|
||||
[/boundaries/montreal-boroughs/?contains=45.524,-73.596](https://represent.opennorth.ca/boundaries/montreal-boroughs/?contains=45.524,-73.596&format=apibrowser)
|
||||
|
||||
To see what boundary sets are available, consult the [boundary sets](https://represent.opennorth.ca/api/#boundaryset) endpoint.
|
||||
|
||||
Find boundaries that touch
|
||||
|
||||
[/boundaries/?touches=alberta-electoral-districts-2017/highwood](https://represent.opennorth.ca/boundaries/?touches=alberta-electoral-districts-2017/highwood&format=apibrowser)
|
||||
|
||||
Find boundaries that intersect (“covers or overlaps” in PostGIS lingo)
|
||||
|
||||
[/boundaries/?intersects=alberta-electoral-districts-2017/highwood](https://represent.opennorth.ca/boundaries/?intersects=alberta-electoral-districts-2017/highwood&format=apibrowser)
|
||||
|
||||
### Drawing boundaries
|
||||
|
||||
We recommend the `simple_shape` endpoint, which simplifies the shape to a tolerance of 0.002, looks fine and loads fast. The default geospatial output format is GeoJSON. Add a `format=kml` or `format=wkt` query parameter to get KML or Well-Known Text.
|
||||
|
||||
Get all simple shapes from a boundary set
|
||||
|
||||
[/boundaries/toronto-wards-2018/simple\_shape](https://represent.opennorth.ca/boundaries/toronto-wards-2018/simple_shape?format=apibrowser)
|
||||
|
||||
Get all original shapes from a boundary set
|
||||
|
||||
[/boundaries/toronto-wards-2018/shape](https://represent.opennorth.ca/boundaries/toronto-wards-2018/shape?format=apibrowser)
|
||||
|
||||
Get all centroids from a boundary set
|
||||
|
||||
[/boundaries/toronto-wards-2018/centroid](https://represent.opennorth.ca/boundaries/toronto-wards-2018/centroid?format=apibrowser)
|
||||
|
||||
Get one boundary's simple shape
|
||||
|
||||
[/boundaries/nova-scotia-electoral-districts-2019/halifax-atlantic/simple\_shape](https://represent.opennorth.ca/boundaries/nova-scotia-electoral-districts-2019/halifax-atlantic/simple_shape?format=apibrowser)
|
||||
|
||||
Get one boundary's original shape
|
||||
|
||||
[/boundaries/nova-scotia-electoral-districts-2019/halifax-atlantic/shape](https://represent.opennorth.ca/boundaries/nova-scotia-electoral-districts-2019/halifax-atlantic/shape?format=apibrowser)
|
||||
|
||||
Get one boundary's centroid
|
||||
|
||||
[/boundaries/nova-scotia-electoral-districts-2019/halifax-atlantic/centroid](https://represent.opennorth.ca/boundaries/nova-scotia-electoral-districts-2019/halifax-atlantic/centroid?format=apibrowser)
|
||||
|
||||
A representative set is a group of elected officials, like the House of Commons or Toronto City Council.
|
||||
|
||||
Do we not have a set of representatives that you need? [Contact us](https://represent.opennorth.ca/api/) so that we can make it a priority.
|
||||
|
||||
Get one page of representative sets
|
||||
|
||||
[/representative-sets/](https://represent.opennorth.ca/representative-sets/?format=apibrowser) Click to view JSON
|
||||
|
||||
Get one representative set
|
||||
|
||||
[/representative-sets/ontario-legislature/](https://represent.opennorth.ca/representative-sets/ontario-legislature/?format=apibrowser)
|
||||
|
||||
Get one page of representatives
|
||||
|
||||
[/representatives/](https://represent.opennorth.ca/representatives/?format=apibrowser) Click to view JSON
|
||||
|
||||
Get one page of representatives from a representative set
|
||||
|
||||
[/representatives/house-of-commons/](https://represent.opennorth.ca/representatives/house-of-commons/?format=apibrowser)
|
||||
|
||||
To see what representative sets are available, consult the [representative sets](https://represent.opennorth.ca/api/#representativeset) endpoint.
|
||||
|
||||
Find all representatives by latitude and longitude
|
||||
|
||||
[/representatives/?point=45.524,-73.596](https://represent.opennorth.ca/representatives/?point=45.524,-73.596&format=apibrowser)
|
||||
|
||||
Find a representative set's representatives by latitude and longitude
|
||||
|
||||
[/representatives/house-of-commons/?point=45.524,-73.596](https://represent.opennorth.ca/representatives/house-of-commons/?point=45.524,-73.596&format=apibrowser)
|
||||
|
||||
To see what representative sets are available, consult the [representative sets](https://represent.opennorth.ca/api/#representativeset) endpoint.
|
||||
|
||||
Get the representatives for one boundary
|
||||
|
||||
[/boundaries/toronto-wards-2018/etobicoke-north-1/representatives/](https://represent.opennorth.ca/boundaries/toronto-wards-2018/etobicoke-north-1/representatives/?format=apibrowser)
|
||||
|
||||
Get the representatives for multiple boundaries (comma-separated)
|
||||
|
||||
[/representatives/?districts=calgary-wards/ward-1,calgary-wards/ward-2,calgary-wards/ward-3](https://represent.opennorth.ca/representatives/?districts=calgary-wards/ward-1,calgary-wards/ward-2,calgary-wards/ward-3&format=apibrowser)
|
||||
|
||||
Filter all representatives by `name`, `first_name`, `last_name`, `gender`, `district_name`, `elected_office` or `party_name`
|
||||
|
||||
[/representatives/?last\_name=Trudeau](https://represent.opennorth.ca/representatives/?last_name=Trudeau&format=apibrowser)
|
||||
|
||||
Filter a representative set's representatives by `name`, `first_name`, `last_name`, `gender`, `district_name`, `elected_office` or `party_name`
|
||||
|
||||
[/representatives/house-of-commons/?last\_name=Trudeau](https://represent.opennorth.ca/representatives/house-of-commons/?last_name=Trudeau&format=apibrowser)
|
||||
|
||||
To see what representative sets are available, consult the [representative sets](https://represent.opennorth.ca/api/#representativeset) endpoint.
|
||||
|
||||
Only the **bold** fields are present in all responses:
|
||||
|
||||
| Field | Example | Notes |
|
||||
| --- | --- | --- |
|
||||
| **name** | Stephen Harper | |
|
||||
| **district\_name** | Calgary Southwest | |
|
||||
| **elected\_office** | MP, MLA, Mayor, Councillor, Alderman | |
|
||||
| **source\_url** | The URL at which the data is scraped | May be the same as `url` below |
|
||||
| first\_name | Stephen | |
|
||||
| last\_name | Harper | |
|
||||
| party\_name | Conservative | |
|
||||
| email | example@example.com | |
|
||||
| url | https://legislature.ca/stephen-harper | The representative’s page on the official legislature site |
|
||||
| photo\_url | https://legislature.ca/stephen-harper.jpg | |
|
||||
| personal\_url | https://stephenharper.blogspot.com/ | A site run by the representative that’s not on the official legislature site |
|
||||
| district\_id | 24013 | If there’s an identifier besides the district name |
|
||||
| gender | M, F | |
|
||||
| offices | `[ {"postal": "10 North Pole, H0H 0H0", "tel": "555-555-5555", "type": "constituency"}, {"tel": "444-444-4444", "type": "legislature"} ]` | A list of objects with contact information for the representative’s offices. The keys are: `postal` (mailing address), `tel` (telephone), `fax` (facsimile), `type` (what kind of office this is, e.g. constituency or legislature). |
|
||||
| extra | `{ "hair_colour": "brown" }` | Any extra data |
|
||||
|
||||
This endpoint behaves like the [/representative-sets/](https://represent.opennorth.ca/api/#representativeset) endpoint. See its documentation for more examples.
|
||||
|
||||
If you would like to add an election to Represent, [contact us](https://represent.opennorth.ca/api/).
|
||||
|
||||
Get one page of elections
|
||||
|
||||
[/elections/](https://represent.opennorth.ca/elections/?format=apibrowser) Click to view JSON
|
||||
|
||||
This endpoint behaves like the [/representatives/](https://represent.opennorth.ca/api/#representative) endpoint. See its documentation for more examples.
|
||||
|
||||
Candidate lists may be incomplete or incorrect, as this information changes frequently.
|
||||
|
||||
If you would like to add candidates to Represent, [contact us](https://represent.opennorth.ca/api/).
|
||||
|
||||
Get one page of candidates
|
||||
|
||||
[/candidates/](https://represent.opennorth.ca/candidates/?format=apibrowser) Click to view JSON
|
||||
@ -1,161 +0,0 @@
|
||||
# CSRF Security Update - Fix Summary
|
||||
|
||||
## Date: October 23, 2025
|
||||
|
||||
## Issues Encountered
|
||||
|
||||
After implementing CSRF security updates, the application experienced two main issues:
|
||||
|
||||
### 1. Login Failed with "Invalid CSRF token"
|
||||
**Problem**: The login endpoint required a CSRF token, but users couldn't get a token before logging in (chicken-and-egg problem).
|
||||
|
||||
**Root Cause**: The `/api/auth/login` endpoint was being protected by CSRF middleware, but there's no session yet during initial login.
|
||||
|
||||
**Solution**: Added `/api/auth/login` and `/api/auth/session` to the CSRF exempt routes list in `app/middleware/csrf.js`. Login endpoints use credentials (username/password) for authentication, so they don't need CSRF protection.
|
||||
|
||||
### 2. Campaign Creation Failed with Infinite Retry Loop
|
||||
**Problem**: When creating campaigns, the app would get stuck in an infinite retry loop with repeated "CSRF token validation failed" errors.
|
||||
|
||||
**Root Causes**:
|
||||
1. The API client (`api-client.js`) wasn't fetching or sending CSRF tokens at all
|
||||
2. The retry logic didn't have a guard against infinite recursion
|
||||
3. FormData wasn't including the CSRF token
|
||||
|
||||
**Solutions**:
|
||||
1. **Added CSRF token management** to the API client:
|
||||
- `fetchCsrfToken()` - Fetches token from `/api/csrf-token` endpoint
|
||||
- `ensureCsrfToken()` - Ensures a valid token exists before requests
|
||||
- Tokens are automatically included in state-changing requests (POST, PUT, PATCH, DELETE)
|
||||
|
||||
2. **Fixed infinite retry loop**:
|
||||
- Added `isRetry` parameter to `makeRequest()`, `postFormData()`, and `putFormData()`
|
||||
- Retry only happens once per request
|
||||
- If second attempt fails, error is thrown to the user
|
||||
|
||||
3. **Enhanced token handling**:
|
||||
- JSON requests: Token sent via `X-CSRF-Token` header
|
||||
- FormData requests: Token sent via `_csrf` field
|
||||
- Token automatically refreshed if server responds with new token
|
||||
|
||||
4. **Server-side updates**:
|
||||
- Added explicit CSRF protection to `/api/csrf-token` endpoint so it can generate tokens
|
||||
- Exported `csrfProtection` middleware for explicit use
|
||||
|
||||
## Files Modified
|
||||
|
||||
### 1. `app/middleware/csrf.js`
|
||||
```javascript
|
||||
// Added to exempt routes:
|
||||
const csrfExemptRoutes = [
|
||||
'/api/health',
|
||||
'/api/metrics',
|
||||
'/api/config',
|
||||
'/api/auth/login', // ← NEW: Login uses credentials
|
||||
'/api/auth/session', // ← NEW: Session check is read-only
|
||||
'/api/representatives/postal/',
|
||||
'/api/campaigns/public'
|
||||
];
|
||||
|
||||
// Enhanced getCsrfToken with error handling
|
||||
```
|
||||
|
||||
### 2. `app/server.js`
|
||||
```javascript
|
||||
// Added csrfProtection to imports
|
||||
const { conditionalCsrfProtection, getCsrfToken, csrfProtection } = require('./middleware/csrf');
|
||||
|
||||
// Applied explicit CSRF protection to token endpoint
|
||||
app.get('/api/csrf-token', csrfProtection, getCsrfToken);
|
||||
```
|
||||
|
||||
### 3. `app/public/js/api-client.js`
|
||||
- Added CSRF token caching and fetching logic
|
||||
- Modified `makeRequest()` to include `X-CSRF-Token` header
|
||||
- Modified `postFormData()` and `putFormData()` to include `_csrf` field
|
||||
- Added retry logic with infinite loop protection (max 1 retry)
|
||||
- Added automatic token refresh on 403 errors
|
||||
|
||||
## How CSRF Protection Works Now
|
||||
|
||||
### Flow for State-Changing Requests (POST, PUT, DELETE):
|
||||
|
||||
```
|
||||
1. User Action (e.g., "Create Campaign")
|
||||
↓
|
||||
2. API Client checks if CSRF token exists
|
||||
↓ (if no token)
|
||||
3. Fetch token from GET /api/csrf-token
|
||||
↓
|
||||
4. Include token in request:
|
||||
- Header: X-CSRF-Token (for JSON)
|
||||
- FormData: _csrf (for file uploads)
|
||||
↓
|
||||
5. Server validates token matches session
|
||||
↓
|
||||
6a. Success → Process request
|
||||
6b. Invalid Token → Return 403
|
||||
↓ (on 403, if not a retry)
|
||||
7. Clear token, fetch new one, retry ONCE
|
||||
↓
|
||||
8a. Success → Return data
|
||||
8b. Still fails → Throw error to user
|
||||
```
|
||||
|
||||
### Protected vs Exempt Endpoints
|
||||
|
||||
**Protected (requires CSRF token)**:
|
||||
- ✅ POST `/api/admin/campaigns` - Create campaign
|
||||
- ✅ PUT `/api/admin/campaigns/:id` - Update campaign
|
||||
- ✅ POST `/api/emails/send` - Send email
|
||||
- ✅ POST `/api/auth/logout` - Logout
|
||||
- ✅ POST `/api/auth/change-password` - Change password
|
||||
|
||||
**Exempt (no CSRF required)**:
|
||||
- ✅ GET (all GET requests are safe)
|
||||
- ✅ POST `/api/auth/login` - Uses credentials
|
||||
- ✅ GET `/api/auth/session` - Read-only check
|
||||
- ✅ GET `/api/health` - Public health check
|
||||
- ✅ GET `/api/metrics` - Prometheus metrics
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [x] Login as admin works
|
||||
- [ ] Create new campaign works
|
||||
- [ ] Update existing campaign works
|
||||
- [ ] Delete campaign works
|
||||
- [ ] Send email to representative works
|
||||
- [ ] Logout works
|
||||
- [ ] Password change works
|
||||
- [ ] Public pages work without authentication
|
||||
|
||||
## Security Benefits
|
||||
|
||||
1. **CSRF Attack Prevention**: Malicious sites can't forge requests to your app
|
||||
2. **Session Hijacking Protection**: httpOnly, secure, sameSite cookies
|
||||
3. **Defense in Depth**: Multiple security layers (Helmet, rate limiting, CSRF, validation)
|
||||
4. **Automatic Token Rotation**: Tokens refresh on each response when available
|
||||
5. **Retry Logic**: Handles token expiration gracefully
|
||||
|
||||
## Important Notes
|
||||
|
||||
- CSRF tokens are tied to sessions and expire with the session (1 hour)
|
||||
- Tokens are stored in cookies (httpOnly, secure in production)
|
||||
- The retry logic prevents infinite loops by limiting to 1 retry per request
|
||||
- Login doesn't need CSRF because it uses credentials for authentication
|
||||
- All state-changing operations (POST/PUT/DELETE) now require valid CSRF tokens
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**If you see "Invalid CSRF token" errors:**
|
||||
|
||||
1. Check browser console for detailed error messages
|
||||
2. Clear browser cookies and session storage
|
||||
3. Logout and login again to get a fresh session
|
||||
4. Verify the session hasn't expired (1 hour timeout)
|
||||
5. Check server logs for CSRF validation failures
|
||||
|
||||
**If infinite retry loop occurs:**
|
||||
|
||||
1. Check that `isRetry` parameter is being passed correctly
|
||||
2. Verify FormData isn't being reused across retries
|
||||
3. Clear the API client's cached token: `window.apiClient.csrfToken = null`
|
||||
@ -1,243 +0,0 @@
|
||||
# Custom Recipients Implementation
|
||||
|
||||
## Overview
|
||||
This feature allows campaigns to target any email address (custom recipients) instead of or in addition to elected representatives from the Represent API.
|
||||
|
||||
## Implementation Summary
|
||||
|
||||
### ✅ Backend (Complete)
|
||||
|
||||
#### 1. Database Schema (`scripts/build-nocodb.sh`)
|
||||
- **custom_recipients table** with fields:
|
||||
- `id` - Primary key
|
||||
- `campaign_id` - Links to campaigns table
|
||||
- `campaign_slug` - Campaign identifier
|
||||
- `recipient_name` - Full name of recipient
|
||||
- `recipient_email` - Email address
|
||||
- `recipient_title` - Job title/position (optional)
|
||||
- `recipient_organization` - Organization name (optional)
|
||||
- `notes` - Internal notes (optional)
|
||||
- `is_active` - Boolean flag
|
||||
|
||||
- **campaigns table** updated:
|
||||
- Added `allow_custom_recipients` boolean field (default: false)
|
||||
|
||||
#### 2. Backend Controller (`app/controllers/customRecipients.js`)
|
||||
Full CRUD operations:
|
||||
- `getRecipientsByCampaign(req, res)` - Fetch all recipients for a campaign
|
||||
- `createRecipient(req, res)` - Add single recipient with validation
|
||||
- `bulkCreateRecipients(req, res)` - Import multiple recipients from CSV
|
||||
- `updateRecipient(req, res)` - Update recipient details
|
||||
- `deleteRecipient(req, res)` - Delete single recipient
|
||||
- `deleteAllRecipients(req, res)` - Clear all recipients for a campaign
|
||||
|
||||
#### 3. NocoDB Service (`app/services/nocodb.js`)
|
||||
- `getCustomRecipients(campaignId)` - Query by campaign ID
|
||||
- `getCustomRecipientsBySlug(campaignSlug)` - Query by slug
|
||||
- `createCustomRecipient(recipientData)` - Create with field mapping
|
||||
- `updateCustomRecipient(recipientId, updateData)` - Partial updates
|
||||
- `deleteCustomRecipient(recipientId)` - Single deletion
|
||||
- `deleteCustomRecipientsByCampaign(campaignId)` - Bulk deletion
|
||||
|
||||
#### 4. API Routes (`app/routes/api.js`)
|
||||
All routes protected with `requireNonTemp` authentication:
|
||||
- `GET /api/campaigns/:slug/custom-recipients` - List all recipients
|
||||
- `POST /api/campaigns/:slug/custom-recipients` - Create single recipient
|
||||
- `POST /api/campaigns/:slug/custom-recipients/bulk` - Bulk import
|
||||
- `PUT /api/campaigns/:slug/custom-recipients/:id` - Update recipient
|
||||
- `DELETE /api/campaigns/:slug/custom-recipients/:id` - Delete recipient
|
||||
- `DELETE /api/campaigns/:slug/custom-recipients` - Delete all recipients
|
||||
|
||||
#### 5. Campaign Controller Updates (`app/controllers/campaigns.js`)
|
||||
- Added `allow_custom_recipients` field to all campaign CRUD operations
|
||||
- Field normalization in 5+ locations for consistent API responses
|
||||
|
||||
### ✅ Frontend (Complete)
|
||||
|
||||
#### 1. JavaScript Module (`app/public/js/custom-recipients.js`)
|
||||
Comprehensive module with:
|
||||
- **CRUD Operations**: Add, edit, delete recipients
|
||||
- **Bulk Import**: CSV file upload or paste with parsing
|
||||
- **Validation**: Email format validation
|
||||
- **UI Management**: Dynamic recipient list display with cards
|
||||
- **Error Handling**: User-friendly error messages
|
||||
- **XSS Protection**: HTML escaping for security
|
||||
|
||||
Key methods:
|
||||
```javascript
|
||||
CustomRecipients.init(campaignSlug) // Initialize module
|
||||
CustomRecipients.loadRecipients(slug) // Load from API
|
||||
CustomRecipients.displayRecipients() // Render list
|
||||
// Plus handleAddRecipient, handleEditRecipient, handleDeleteRecipient, etc.
|
||||
```
|
||||
|
||||
#### 2. Admin Panel Integration (`app/public/admin.html` + `app/public/js/admin.js`)
|
||||
- **Create Form**: Checkbox to enable custom recipients
|
||||
- **Edit Form**:
|
||||
- Checkbox with show/hide toggle
|
||||
- Add recipient form (5 fields: name, email, title, organization, notes)
|
||||
- Bulk CSV import button with modal
|
||||
- Recipients list with edit/delete actions
|
||||
- Clear all button
|
||||
- **JavaScript Integration**:
|
||||
- `toggleCustomRecipientsSection()` - Show/hide based on checkbox
|
||||
- `setupCustomRecipientsHandlers()` - Event listeners for checkbox
|
||||
- Auto-load recipients when editing campaign with feature enabled
|
||||
- Form data includes `allow_custom_recipients` in create/update
|
||||
|
||||
#### 3. Bulk Import Modal (`app/public/admin.html`)
|
||||
Complete modal with:
|
||||
- CSV format instructions
|
||||
- File upload input
|
||||
- Paste textarea for direct CSV input
|
||||
- Import results display with success/failure details
|
||||
- CSV format: `recipient_name,recipient_email,recipient_title,recipient_organization,notes`
|
||||
|
||||
#### 4. CSS Styling (`app/public/admin.html`)
|
||||
- `.recipient-card` - Card layout with hover effects
|
||||
- `.recipient-info` - Name, email, metadata display
|
||||
- `.recipient-actions` - Edit/delete icon buttons with hover colors
|
||||
- `.bulk-import-help` - Modal styling
|
||||
- Responsive grid layout
|
||||
|
||||
## Usage
|
||||
|
||||
### For Administrators:
|
||||
|
||||
1. **Create Campaign**:
|
||||
- Check "Allow Custom Recipients" during creation
|
||||
- An info section will appear explaining that recipients can be added after campaign is created
|
||||
- Complete the campaign creation
|
||||
|
||||
2. **Edit Campaign**:
|
||||
- Navigate to the Edit tab and select your campaign
|
||||
- Check "Allow Custom Recipients" to enable the feature
|
||||
- The custom recipients management section will appear below the checkbox
|
||||
|
||||
3. **Add Single Recipient**:
|
||||
- Fill in name (required) and email (required)
|
||||
- Optionally add title, organization, notes
|
||||
- Click "Add Recipient"
|
||||
|
||||
4. **Bulk Import**:
|
||||
- Click "Bulk Import (CSV)" button
|
||||
- Upload CSV file or paste CSV data
|
||||
- CSV format: `recipient_name,recipient_email,recipient_title,recipient_organization,notes`
|
||||
- First row can be header (will be skipped if contains "recipient_name")
|
||||
- Results show success/failure for each row
|
||||
|
||||
5. **Edit Recipient**:
|
||||
- Click edit icon on recipient card
|
||||
- Form populates with current data
|
||||
- Make changes and click "Update Recipient"
|
||||
- Or click "Cancel" to revert
|
||||
|
||||
6. **Delete Recipients**:
|
||||
- Single: Click delete icon on card
|
||||
- All: Click "Clear All" button
|
||||
|
||||
### API Examples:
|
||||
|
||||
```bash
|
||||
# Create recipient
|
||||
curl -X POST /api/campaigns/my-campaign/custom-recipients \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"recipient_name": "Jane Doe",
|
||||
"recipient_email": "jane@example.com",
|
||||
"recipient_title": "CEO",
|
||||
"recipient_organization": "Tech Corp"
|
||||
}'
|
||||
|
||||
# Bulk import
|
||||
curl -X POST /api/campaigns/my-campaign/custom-recipients/bulk \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"recipients": [
|
||||
{"recipient_name": "John Smith", "recipient_email": "john@example.com"},
|
||||
{"recipient_name": "Jane Doe", "recipient_email": "jane@example.com"}
|
||||
]
|
||||
}'
|
||||
|
||||
# Get all recipients
|
||||
curl /api/campaigns/my-campaign/custom-recipients
|
||||
|
||||
# Update recipient
|
||||
curl -X PUT /api/campaigns/my-campaign/custom-recipients/123 \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"recipient_title": "CTO"}'
|
||||
|
||||
# Delete recipient
|
||||
curl -X DELETE /api/campaigns/my-campaign/custom-recipients/123
|
||||
|
||||
# Delete all recipients
|
||||
curl -X DELETE /api/campaigns/my-campaign/custom-recipients
|
||||
```
|
||||
|
||||
## Security Features
|
||||
|
||||
- **Authentication**: All API routes require non-temporary user session
|
||||
- **Validation**: Email format validation on client and server
|
||||
- **XSS Protection**: HTML escaping in display
|
||||
- **Campaign Check**: Verifies campaign exists and feature is enabled
|
||||
- **Input Sanitization**: express-validator on API endpoints
|
||||
|
||||
## Next Steps (TODO)
|
||||
|
||||
1. **Dashboard Integration**: Add same UI to `dashboard.html` for regular users
|
||||
2. **Campaign Display**: Update `campaign.js` to show custom recipients alongside elected officials
|
||||
3. **Email Composer**: Ensure custom recipients work in email sending flow
|
||||
4. **Testing**: Comprehensive end-to-end testing
|
||||
5. **Documentation**: Update main README and files-explainer
|
||||
|
||||
## Files Modified/Created
|
||||
|
||||
### Backend:
|
||||
- ✅ `scripts/build-nocodb.sh` - Database schema
|
||||
- ✅ `app/controllers/customRecipients.js` - NEW FILE (282 lines)
|
||||
- ✅ `app/services/nocodb.js` - Service methods
|
||||
- ✅ `app/routes/api.js` - API endpoints
|
||||
- ✅ `app/controllers/campaigns.js` - Field updates
|
||||
|
||||
### Frontend:
|
||||
- ✅ `app/public/js/custom-recipients.js` - NEW FILE (538 lines)
|
||||
- ✅ `app/public/js/admin.js` - Integration code
|
||||
- ✅ `app/public/admin.html` - UI components and forms
|
||||
|
||||
### Documentation:
|
||||
- ✅ `CUSTOM_RECIPIENTS_IMPLEMENTATION.md` - This file
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] Database table creation (run build-nocodb.sh)
|
||||
- [ ] Create campaign with custom recipients enabled
|
||||
- [ ] Add single recipient via form
|
||||
- [ ] Edit recipient information
|
||||
- [ ] Delete single recipient
|
||||
- [ ] Bulk import via CSV file
|
||||
- [ ] Bulk import via paste
|
||||
- [ ] Clear all recipients
|
||||
- [ ] Toggle checkbox on/off
|
||||
- [ ] Verify API authentication
|
||||
- [ ] Test with campaign where feature is disabled
|
||||
- [ ] Check recipient display on campaign page
|
||||
- [ ] Test email sending to custom recipients
|
||||
|
||||
## Known Limitations
|
||||
|
||||
1. Custom recipients can only be added AFTER campaign is created (not during creation)
|
||||
2. Dashboard UI not yet implemented (admin panel only)
|
||||
3. Campaign display page doesn't show custom recipients yet
|
||||
4. CSV import uses simple comma splitting (doesn't handle quoted commas)
|
||||
5. No duplicate email detection
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] Duplicate email detection/prevention
|
||||
- [ ] Import validation preview before saving
|
||||
- [ ] Export recipients to CSV
|
||||
- [ ] Recipient groups/categories
|
||||
- [ ] Import from external sources (Google Contacts, etc.)
|
||||
- [ ] Recipient engagement tracking
|
||||
- [ ] Custom fields for recipients
|
||||
- [ ] Merge tags in email templates using recipient data
|
||||
@ -1,119 +0,0 @@
|
||||
# Debugging Custom Recipients Feature
|
||||
|
||||
## Changes Made to Fix Checkbox Toggle
|
||||
|
||||
### Issue
|
||||
The "Allow Custom Recipients" checkbox wasn't showing/hiding the custom recipients management section when clicked.
|
||||
|
||||
### Root Causes
|
||||
1. **Event Listener Timing**: Original code tried to attach event listeners during `init()`, but the edit form elements didn't exist yet
|
||||
2. **Not Following Best Practices**: Wasn't using event delegation pattern as required by `instruct.md`
|
||||
|
||||
### Solution
|
||||
Switched to **event delegation** pattern using a single document-level listener:
|
||||
|
||||
```javascript
|
||||
// OLD (didn't work - elements didn't exist yet):
|
||||
const editCheckbox = document.getElementById('edit-allow-custom-recipients');
|
||||
if (editCheckbox) {
|
||||
editCheckbox.addEventListener('change', handler);
|
||||
}
|
||||
|
||||
// NEW (works with event delegation):
|
||||
document.addEventListener('change', (e) => {
|
||||
if (e.target.id === 'edit-allow-custom-recipients') {
|
||||
// Handle the change
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Benefits of Event Delegation
|
||||
1. ✅ Works regardless of when elements are added to DOM
|
||||
2. ✅ Follows `instruct.md` rules about using `addEventListener`
|
||||
3. ✅ No need to reattach listeners when switching tabs
|
||||
4. ✅ Single listener handles all checkbox changes efficiently
|
||||
|
||||
### Console Logs Added for Debugging
|
||||
The following console logs were added to help trace execution:
|
||||
|
||||
1. **admin.js init()**: "AdminPanel init started" and "AdminPanel init completed"
|
||||
2. **custom-recipients.js load**: "Custom Recipients module loading..." and "Custom Recipients module initialized"
|
||||
3. **setupCustomRecipientsHandlers()**: "Setting up custom recipients handlers" and "Custom recipients handlers set up with event delegation"
|
||||
4. **Checkbox change**: "Custom recipients checkbox changed: true/false"
|
||||
5. **Module init**: "Initializing CustomRecipients module for campaign: [slug]"
|
||||
6. **toggleCustomRecipientsSection()**: "Toggling custom recipients section: true/false" and "Section display set to: block/none"
|
||||
|
||||
### Testing Steps
|
||||
|
||||
1. **Open Browser Console** (F12)
|
||||
2. **Navigate to Admin Panel** → Look for "AdminPanel init started"
|
||||
3. **Look for Module Load** → "Custom Recipients module loading..."
|
||||
|
||||
**Test Create Form:**
|
||||
4. **Switch to Create Tab** → Click "Create New Campaign"
|
||||
5. **Check the Checkbox** → "Allow Custom Recipients"
|
||||
6. **Verify Info Section Appears** → Should see: "Custom recipients can only be added after the campaign is created"
|
||||
7. **Console Should Show**: "Create form: Custom recipients checkbox changed: true"
|
||||
|
||||
**Test Edit Form:**
|
||||
8. **Switch to Edit Tab** → Select a campaign
|
||||
9. **Check the Checkbox** → "Allow Custom Recipients"
|
||||
10. **You Should See**:
|
||||
- "Custom recipients checkbox changed: true"
|
||||
- "Toggling custom recipients section: true"
|
||||
- "Section display set to: block"
|
||||
- "Initializing CustomRecipients module for campaign: [slug]"
|
||||
11. **Verify Section Appears** → The "Manage Custom Recipients" section with forms should now be visible
|
||||
|
||||
### If It Still Doesn't Work
|
||||
|
||||
Check the following in browser console:
|
||||
|
||||
1. **Are scripts loading?**
|
||||
```
|
||||
Look for: "Custom Recipients module loading..."
|
||||
If missing: Check network tab for 404 errors on custom-recipients.js
|
||||
```
|
||||
|
||||
2. **Is event delegation working?**
|
||||
```
|
||||
Look for: "Custom recipients handlers set up with event delegation"
|
||||
If missing: Check if setupCustomRecipientsHandlers() is being called
|
||||
```
|
||||
|
||||
3. **Is checkbox being detected?**
|
||||
```
|
||||
Click checkbox and look for: "Custom recipients checkbox changed: true"
|
||||
If missing: Check if checkbox ID is correct in HTML
|
||||
```
|
||||
|
||||
4. **Is section element found?**
|
||||
```
|
||||
Look for: "section found: [object HTMLDivElement]"
|
||||
If it says "section found: null": Check if section ID matches in HTML
|
||||
```
|
||||
|
||||
5. **Manual test in console:**
|
||||
```javascript
|
||||
// Check if checkbox exists
|
||||
document.getElementById('edit-allow-custom-recipients')
|
||||
|
||||
// Check if section exists
|
||||
document.getElementById('edit-custom-recipients-section')
|
||||
|
||||
// Check if module loaded
|
||||
window.CustomRecipients
|
||||
|
||||
// Manually toggle section
|
||||
document.getElementById('edit-custom-recipients-section').style.display = 'block';
|
||||
```
|
||||
|
||||
### Files Modified
|
||||
- ✅ `app/public/js/admin.js` - Changed to event delegation pattern, added logging
|
||||
- ✅ `app/public/js/custom-recipients.js` - Added loading logs
|
||||
- ✅ No changes needed to HTML (already correct)
|
||||
|
||||
### Next Steps After Confirming It Works
|
||||
1. Remove excessive console.log statements (or convert to debug mode)
|
||||
2. Test full workflow: add recipient, edit, delete, bulk import
|
||||
3. Proceed with dashboard.html integration
|
||||
@ -1,575 +0,0 @@
|
||||
# BNKops Influence Campaign Tool
|
||||
|
||||
A comprehensive web application that helps Alberta residents connect with their elected representatives across all levels of government. Users can find their representatives by postal code and send direct emails to advocate for important issues.
|
||||
|
||||
## Features
|
||||
|
||||
- **Representative Lookup**: Find elected officials by Alberta postal code (T prefixed)
|
||||
- **Multi-Level Government**: Displays federal MPs, provincial MLAs, and municipal representatives
|
||||
- **Contact Information**: Shows photos, email addresses, phone numbers, and office locations
|
||||
- **Direct Email**: Built-in email composer to contact representatives
|
||||
- **Campaign Management**: Create and manage advocacy campaigns with customizable settings
|
||||
- **Public Campaigns Grid**: Homepage display of all active campaigns for easy discovery and participation
|
||||
- **Response Wall**: Community-driven platform for sharing and voting on representative responses
|
||||
- **Email Count Display**: Optional engagement metrics showing total emails sent per campaign
|
||||
- **Smart Caching**: Fast performance with NocoDB caching and graceful fallback to live API
|
||||
- **Responsive Design**: Works seamlessly on desktop and mobile devices
|
||||
- **Real-time Data**: Integrates with Represent OpenNorth API for up-to-date information
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Backend**: Node.js with Express.js
|
||||
- **Database**: NocoDB (REST API)
|
||||
- **External API**: Represent OpenNorth Canada API
|
||||
- **Frontend**: Vanilla JavaScript, HTML5, CSS3
|
||||
- **Email**: SMTP integration
|
||||
- **Deployment**: Docker with docker-compose
|
||||
- **Rate Limiting**: Express rate limiter for API protection
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
- Docker and Docker Compose
|
||||
- Access to existing NocoDB instance
|
||||
- SMTP email configuration
|
||||
|
||||
### Installation
|
||||
|
||||
1. **Clone and navigate to the project**:
|
||||
```bash
|
||||
cd /path/to/changemaker.lite/influence
|
||||
```
|
||||
|
||||
2. **Configure environment**:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with your configuration
|
||||
```
|
||||
|
||||
3. **Set up NocoDB tables**:
|
||||
```bash
|
||||
./scripts/build-nocodb.sh
|
||||
```
|
||||
|
||||
4. **Start the application**:
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
5. **Access the application**:
|
||||
- Open http://localhost:3333
|
||||
- Enter an Alberta postal code (e.g., T5N4B8)
|
||||
- View your representatives and send emails
|
||||
|
||||
## Development Mode
|
||||
|
||||
### Email Testing with MailHog
|
||||
|
||||
For development and testing, the application includes MailHog integration to safely test email functionality without sending real emails to elected officials.
|
||||
|
||||
#### Quick Setup for Development
|
||||
|
||||
1. **Use development configuration**:
|
||||
```bash
|
||||
# Your .env should include these settings for development:
|
||||
NODE_ENV=development
|
||||
EMAIL_TEST_MODE=true
|
||||
SMTP_HOST=mailhog
|
||||
SMTP_PORT=1025
|
||||
SMTP_USER=test
|
||||
SMTP_PASS=test
|
||||
TEST_EMAIL_RECIPIENT=your-email@example.com
|
||||
```
|
||||
|
||||
2. **Start with MailHog included**:
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
3. **Access development tools**:
|
||||
- **Application**: http://localhost:3333
|
||||
- **Email Testing Interface**: http://localhost:3333/email-test.html (admin login required)
|
||||
- **MailHog Web UI**: http://localhost:8025 (view all caught emails)
|
||||
|
||||
#### Email Testing Features
|
||||
|
||||
**Test Mode Benefits:**
|
||||
- ✅ All emails redirected to your test recipient
|
||||
- ✅ Original recipient shown in subject line: `[TEST - Original: real@email.com] Subject`
|
||||
- ✅ Safe testing without spamming elected officials
|
||||
- ✅ Complete email logging with test mode indicators
|
||||
|
||||
**Email Testing Interface** (`/email-test.html`):
|
||||
- **Quick Test**: Send test email with one click
|
||||
- **Email Preview**: See exactly how emails will look before sending
|
||||
- **Custom Composition**: Test with your own subject and message content
|
||||
- **Email Logs**: View all sent emails with test/live filtering
|
||||
- **SMTP Diagnostics**: Test connection and troubleshoot issues
|
||||
|
||||
**MailHog Web Interface** (`http://localhost:8025`):
|
||||
- View all emails caught during development
|
||||
- Inspect email content, headers, and formatting
|
||||
- Search and filter caught emails
|
||||
- No emails leave your local environment
|
||||
|
||||
#### Development Workflow
|
||||
|
||||
1. **Safe Development**:
|
||||
```bash
|
||||
# Ensure test mode is enabled
|
||||
EMAIL_TEST_MODE=true
|
||||
|
||||
# Start development environment
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
2. **Test Email Functionality**:
|
||||
- Use the main app to send emails (they'll be redirected)
|
||||
- Check MailHog UI to see the actual email content
|
||||
- Use `/email-test.html` for advanced testing and preview
|
||||
|
||||
3. **Production Deployment**:
|
||||
```bash
|
||||
# Switch to production SMTP settings
|
||||
EMAIL_TEST_MODE=false
|
||||
SMTP_HOST=smtp.your-provider.com
|
||||
SMTP_USER=your-real-email@domain.com
|
||||
SMTP_PASS=your-real-password
|
||||
|
||||
# Restart application
|
||||
docker compose restart
|
||||
```
|
||||
|
||||
#### Development Environment Variables
|
||||
|
||||
```bash
|
||||
# Development Mode Configuration
|
||||
NODE_ENV=development
|
||||
EMAIL_TEST_MODE=true
|
||||
|
||||
# MailHog SMTP (for development)
|
||||
SMTP_HOST=mailhog
|
||||
SMTP_PORT=1025
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=test
|
||||
SMTP_PASS=test
|
||||
SMTP_FROM_EMAIL=dev@albertainfluence.local
|
||||
SMTP_FROM_NAME="BNKops Influence Campaign (DEV)"
|
||||
|
||||
# Email Testing
|
||||
TEST_EMAIL_RECIPIENT=developer@example.com
|
||||
|
||||
# Production SMTP (commented out for dev)
|
||||
# SMTP_HOST=smtp.protonmail.ch
|
||||
# SMTP_PORT=587
|
||||
# SMTP_USER=your-production-email@domain.com
|
||||
# SMTP_PASS=your-production-password
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables (.env)
|
||||
|
||||
```bash
|
||||
# Server Configuration
|
||||
NODE_ENV=production
|
||||
PORT=3333
|
||||
|
||||
# NocoDB Configuration
|
||||
NOCODB_API_URL=https://db.cmlite.org
|
||||
NOCODB_API_TOKEN=your_nocodb_token
|
||||
NOCODB_PROJECT_ID=your_project_id
|
||||
|
||||
# Email Configuration (SMTP)
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
SMTP_USER=your_email@gmail.com
|
||||
SMTP_PASS=your_app_password
|
||||
SMTP_FROM_NAME=BNKops Influence Campaign
|
||||
SMTP_FROM_EMAIL=your_email@gmail.com
|
||||
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_WINDOW_MS=900000
|
||||
RATE_LIMIT_MAX_REQUESTS=100
|
||||
```
|
||||
|
||||
## Campaign Management Guide
|
||||
|
||||
### Creating a Campaign
|
||||
|
||||
1. **Access Admin Panel**: Navigate to `/admin.html` and log in with admin credentials
|
||||
2. **Create New Campaign**: Click "Create Campaign" button
|
||||
3. **Configure Basic Settings**:
|
||||
- **Campaign Title**: Short, descriptive name (becomes the URL slug)
|
||||
- **Description**: Brief overview shown on the campaign landing page
|
||||
- **Call to Action**: Motivational message encouraging participation
|
||||
|
||||
4. **Set Email Template**:
|
||||
- **Email Subject**: Pre-filled subject line for emails
|
||||
- **Email Body**: Default message template (users may edit if allowed)
|
||||
|
||||
5. **Upload Cover Photo** (Optional):
|
||||
- Click "Choose File" to upload a hero image
|
||||
- Supported formats: JPEG, PNG, GIF, WebP
|
||||
- Maximum size: 5MB
|
||||
- Image displays as campaign page banner
|
||||
|
||||
6. **Configure Campaign Settings**:
|
||||
- **📧 Allow SMTP Email**: Enable server-side email sending
|
||||
- **🔗 Allow Mailto Link**: Enable browser-based mailto: links
|
||||
- **👤 Collect User Info**: Request user name and email
|
||||
- **📊 Show Email Count**: Display total emails sent (engagement metric)
|
||||
- **✏️ Allow Email Editing**: Let users customize email template
|
||||
- **⭐ Highlight Campaign**: Feature this campaign on the homepage (replaces postal code search)
|
||||
- **🎯 Target Government Levels**: Select Federal, Provincial, Municipal, School Board
|
||||
|
||||
7. **Set Campaign Status**:
|
||||
- **Draft**: Hidden from public, testing mode
|
||||
- **Active**: Visible to public on main page
|
||||
- **Paused**: Temporarily disabled
|
||||
- **Archived**: Completed campaigns
|
||||
|
||||
8. **Save Campaign**: Click "Create Campaign" to publish
|
||||
|
||||
### Public Campaigns Display
|
||||
|
||||
The homepage automatically displays all active campaigns in a responsive grid below the representative lookup section.
|
||||
|
||||
**Features**:
|
||||
- **Automatic Display**: Only active campaigns (status="active") are shown publicly
|
||||
- **Campaign Cards**: Each campaign displays as an attractive card with:
|
||||
- Cover photo (if uploaded) or gradient background
|
||||
- Campaign title and truncated description
|
||||
- Target government level badges (Federal, Provincial, Municipal, etc.)
|
||||
- Email count badge (if enabled via campaign settings)
|
||||
- "Learn More & Participate" call-to-action
|
||||
- **Responsive Grid**: Automatically adjusts columns based on screen size
|
||||
- Desktop: 3-4 columns
|
||||
- Tablet: 2 columns
|
||||
- Mobile: 1 column
|
||||
- **Click Navigation**: Users can click any campaign card to visit the full campaign page
|
||||
|
||||
### Highlighted Campaign Feature
|
||||
|
||||
Promote a priority campaign by highlighting it on the homepage, replacing the postal code search section with featured campaign information.
|
||||
|
||||
**How to Highlight a Campaign**:
|
||||
1. Navigate to Admin Panel → Edit Campaign
|
||||
2. Select the campaign you want to feature
|
||||
3. Check the "⭐ Highlight Campaign" checkbox
|
||||
4. Save changes
|
||||
|
||||
**Highlighted Campaign Display**:
|
||||
- **Homepage Takeover**: Replaces postal code search with campaign showcase
|
||||
- **Featured Badge**: Shows "⭐ Featured Campaign" badge
|
||||
- **Campaign Details**: Displays title, description, and engagement stats
|
||||
- **Primary CTA**: Large "Join This Campaign" button
|
||||
- **Fallback Option**: "Find Representatives by Postal Code" button for users who want standard lookup
|
||||
- **Visual Indicators**: Gold border and badge in admin panel campaign list
|
||||
|
||||
**Important Notes**:
|
||||
- **One at a Time**: Only ONE campaign can be highlighted simultaneously
|
||||
- **Auto-Unset**: Setting a new highlighted campaign automatically removes highlighting from previous campaign
|
||||
- **Requires Active Status**: Campaign must have status="active" to be highlighted
|
||||
- **Admin Control**: Only administrators can set highlighted campaigns
|
||||
|
||||
**Technical Implementation**:
|
||||
- Backend validates and ensures single highlighted campaign via `setHighlightedCampaign()`
|
||||
- Frontend checks `/public/highlighted-campaign` API on page load
|
||||
- Postal code lookup remains accessible via button click
|
||||
- Highlighting state persists across page reloads
|
||||
|
||||
- **Smart Loading**: Shows loading state while fetching campaigns, gracefully hides section if no active campaigns exist
|
||||
- **Security**: HTML content is escaped to prevent XSS attacks
|
||||
- **Sorting**: Campaigns display newest first by creation date
|
||||
|
||||
**Public API Endpoint**: `/api/public/campaigns` (no authentication required)
|
||||
- Returns only campaigns with `status='active'`
|
||||
- Includes email counts when `show_email_count=true`
|
||||
- Optimized for performance with minimal data transfer
|
||||
|
||||
### Email Count Display Feature
|
||||
|
||||
The **Show Email Count** setting controls whether campaign pages display total engagement metrics.
|
||||
|
||||
**When Enabled** (✅ checked):
|
||||
- Campaign page shows: "X Albertans have sent emails through this campaign"
|
||||
- Provides social proof and encourages participation
|
||||
- Updates in real-time as users send emails
|
||||
- Displays prominently above the call-to-action
|
||||
|
||||
**When Disabled** (❌ unchecked):
|
||||
- Email count section is hidden
|
||||
- Useful for sensitive campaigns or privacy concerns
|
||||
- Engagement metrics still tracked in admin panel
|
||||
|
||||
**Best Practices**:
|
||||
- ✅ Enable for public awareness campaigns to show momentum
|
||||
- ✅ Enable for volunteer recruitment to demonstrate support
|
||||
- ❌ Disable for personal advocacy or sensitive issues
|
||||
- ❌ Disable for new campaigns until participation grows
|
||||
|
||||
**Technical Details**:
|
||||
- Count includes all successfully sent emails via campaign
|
||||
- Tracks both SMTP-sent and mailto-initiated emails (if logged)
|
||||
- Admin panel always shows counts regardless of public display setting
|
||||
- Database field: `show_email_count` (checkbox, default: true)
|
||||
|
||||
### Editing Campaigns
|
||||
|
||||
1. Navigate to Admin Panel → "Campaigns" tab
|
||||
2. Find campaign card and click "Edit"
|
||||
3. Modify any settings including the email count display toggle
|
||||
4. Save changes - updates apply immediately to public-facing page
|
||||
|
||||
### Campaign Analytics
|
||||
|
||||
Access campaign performance metrics in the Admin Panel:
|
||||
- Total emails sent per campaign
|
||||
- User participation rates
|
||||
- Email delivery status
|
||||
- Representative contact distribution
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Representatives
|
||||
- `GET /api/representatives/by-postal/:postalCode` - Get representatives by postal code
|
||||
- `POST /api/representatives/refresh-postal/:postalCode` - Refresh cached data
|
||||
|
||||
### Email
|
||||
- `POST /api/emails/send` - Send email to representative
|
||||
- `GET /api/emails/logs` - Get email sending logs (with filters)
|
||||
|
||||
### Email Testing (Development)
|
||||
- `POST /api/emails/preview` - Preview email without sending (admin only)
|
||||
- `POST /api/emails/test` - Send test email to configured recipient (admin only)
|
||||
- `GET /api/test-smtp` - Test SMTP connection (admin only)
|
||||
|
||||
### Health
|
||||
- `GET /api/health` - Application health check
|
||||
- `GET /api/test-represent` - Test Represent API connection
|
||||
|
||||
## Database Schema
|
||||
|
||||
### Campaigns Table
|
||||
- slug, title, description
|
||||
- email_subject, email_body
|
||||
- call_to_action, cover_photo
|
||||
- status (draft/active/paused/archived)
|
||||
- allow_smtp_email, allow_mailto_link
|
||||
- collect_user_info, **show_email_count**
|
||||
- allow_email_editing
|
||||
- target_government_levels (MultiSelect)
|
||||
- created_by_user_id, created_by_user_email, created_by_user_name
|
||||
|
||||
### Campaign Emails Table
|
||||
- campaign_id, user_name, user_email, user_postal_code
|
||||
- recipient_name, recipient_email, recipient_level
|
||||
- subject, message, status, sent_at
|
||||
|
||||
### Representatives Table
|
||||
- postal_code, name, email, district_name
|
||||
- elected_office, party_name, representative_set_name
|
||||
- url, photo_url, cached_at
|
||||
|
||||
### Email Logs Table
|
||||
- recipient_email, recipient_name, sender_email
|
||||
- subject, message, status, sent_at
|
||||
|
||||
### Postal Codes Table
|
||||
- postal_code, city, province
|
||||
- centroid_lat, centroid_lng, last_updated
|
||||
|
||||
### Users Table
|
||||
- email, password_hash, name
|
||||
- role (admin/user), status (active/temporary)
|
||||
- expires_at, last_login
|
||||
|
||||
## Development
|
||||
|
||||
### Project Structure
|
||||
```
|
||||
influence/
|
||||
├── app/
|
||||
│ ├── controllers/ # Business logic
|
||||
│ ├── routes/ # API routes
|
||||
│ ├── services/ # External integrations
|
||||
│ ├── utils/ # Helper functions
|
||||
│ ├── middleware/ # Express middleware
|
||||
│ ├── public/ # Frontend assets
|
||||
│ └── server.js # Express app entry point
|
||||
├── scripts/
|
||||
│ └── build-nocodb.sh # Database setup
|
||||
├── docker-compose.yml # Container orchestration
|
||||
├── Dockerfile # Container definition
|
||||
└── .env # Environment configuration
|
||||
```
|
||||
|
||||
### Key Components
|
||||
|
||||
- **RepresentativesController**: Handles postal code lookups and caching
|
||||
- **EmailController**: Manages email composition, sending, and testing
|
||||
- **NocoDBService**: Database operations with error handling
|
||||
- **RepresentAPI**: Integration with OpenNorth Represent API
|
||||
- **EmailService**: SMTP email functionality with test mode support
|
||||
- **Email Testing System**: Preview, test, and log email functionality for development
|
||||
|
||||
## Features in Detail
|
||||
|
||||
### Smart Caching System
|
||||
- First request fetches from Represent API and caches in NocoDB
|
||||
- Subsequent requests served from cache for fast performance
|
||||
- Graceful fallback to API when NocoDB is unavailable
|
||||
- Automatic error recovery and retry logic
|
||||
|
||||
### Representative Display
|
||||
- Shows photo with fallback to initials
|
||||
- Contact information including phone and address
|
||||
- Party affiliation and government level
|
||||
- Direct links to official profiles
|
||||
|
||||
### Campaign System
|
||||
- **Campaign Creation**: Create advocacy campaigns with custom titles, descriptions, and email templates
|
||||
- **Cover Photos**: Upload hero images for campaign landing pages (JPEG/PNG/GIF/WebP, max 5MB)
|
||||
- **Flexible Email Methods**: Choose between SMTP email or mailto links for user convenience
|
||||
- **User Info Collection**: Optional name/email collection for campaign tracking
|
||||
- **Email Count Display**: Show total engagement metrics on campaign pages (toggle on/off)
|
||||
- **Email Editing**: Allow users to customize campaign email templates (optional)
|
||||
- **Target Levels**: Select which government levels to target (Federal/Provincial/Municipal/School Board)
|
||||
- **Campaign Status**: Draft, Active, Paused, or Archived workflow states
|
||||
|
||||
### Response Wall Feature
|
||||
The Response Wall creates transparency and accountability by allowing campaign participants to share responses they receive from elected representatives.
|
||||
|
||||
**Key Features:**
|
||||
- **Public Response Sharing**: Constituents can post responses received via email, letter, phone, meetings, or social media
|
||||
- **Community Voting**: Upvote system highlights helpful and representative responses
|
||||
- **Verification System**: Admin-moderated verification badges for authentic responses
|
||||
- **Screenshot Support**: Upload visual proof of responses (images up to 5MB)
|
||||
- **Anonymous Posting**: Option to share responses without revealing identity
|
||||
- **Filtering & Sorting**: Filter by government level, sort by recent/upvotes/verified
|
||||
- **Engagement Statistics**: Track total responses, verified count, and community upvotes
|
||||
- **Moderation Queue**: Admin panel for approving, rejecting, or verifying submitted responses
|
||||
- **Campaign Integration**: Response walls linked to specific campaigns for contextualized feedback
|
||||
|
||||
**Access Response Wall:**
|
||||
- Via campaign page: Add `?campaign=your-campaign-slug` parameter to `/response-wall.html`
|
||||
- Admin moderation: Navigate to "Response Moderation" tab in admin panel
|
||||
- Public viewing: All approved responses visible to encourage participation
|
||||
|
||||
**Moderation Workflow:**
|
||||
1. Users submit responses with required details (representative name, level, type, response text)
|
||||
2. Submissions enter "pending" status in moderation queue
|
||||
3. Admins review and approve/reject from admin panel
|
||||
4. Approved responses appear on public Response Wall
|
||||
5. Admins can mark verified responses with special badge
|
||||
6. Community upvotes highlight most impactful responses
|
||||
|
||||
### QR Code Sharing Feature
|
||||
The application includes dynamic QR code generation for easy campaign and response wall sharing.
|
||||
|
||||
**Key Features:**
|
||||
- **Campaign QR Codes**: Generate scannable QR codes for campaign pages
|
||||
- **Response Wall QR Codes**: Share response walls with QR codes for mobile scanning
|
||||
- **High-Quality Generation**: 400x400px PNG images with high error correction (level H)
|
||||
- **Download Support**: One-click download of QR code images for printing or sharing
|
||||
- **Social Integration**: QR code button alongside social share buttons (Facebook, Twitter, LinkedIn, etc.)
|
||||
- **Caching**: QR codes cached for 1 hour to improve performance
|
||||
|
||||
**How to Use:**
|
||||
1. Visit any campaign page or response wall
|
||||
2. Click the QR code icon (📱) in the social share buttons section
|
||||
3. A modal appears with the generated QR code
|
||||
4. Scan with any smartphone camera to visit the page
|
||||
5. Click "Download QR Code" to save the image for printing or sharing
|
||||
|
||||
**Technical Implementation:**
|
||||
- Backend endpoint: `GET /api/campaigns/:slug/qrcode?type=campaign|response-wall`
|
||||
- Uses `qrcode` npm package for generation
|
||||
- Proper MIME type and cache headers
|
||||
- Modal UI with download functionality
|
||||
|
||||
**Use Cases:**
|
||||
- Print QR codes on flyers and posters for offline campaign promotion
|
||||
- Share QR codes in presentations and meetings
|
||||
- Include in email newsletters for mobile-friendly access
|
||||
- Display at events for easy sign-up
|
||||
|
||||
### Email Integration
|
||||
- Modal-based email composer
|
||||
- Pre-filled recipient information
|
||||
- SMTP sending with delivery confirmation
|
||||
- Email history and logging
|
||||
|
||||
### Error Handling
|
||||
- Comprehensive error logging
|
||||
- User-friendly error messages
|
||||
- API fallback mechanisms
|
||||
- Rate limiting protection
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Docker Production
|
||||
```bash
|
||||
# Build and start in production mode
|
||||
docker compose -f docker-compose.yml up -d --build
|
||||
|
||||
# View logs
|
||||
docker compose logs -f app
|
||||
|
||||
# Scale if needed
|
||||
docker compose up --scale app=2
|
||||
```
|
||||
|
||||
### Monitoring
|
||||
- Health check endpoint: `/api/health`
|
||||
- Application logs via Docker
|
||||
- NocoDB integration status monitoring
|
||||
- Email delivery tracking
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **NocoDB Connection Errors**:
|
||||
- Check API URL and token in .env
|
||||
- Run `./scripts/build-nocodb.sh` to setup tables
|
||||
- Application works without NocoDB (API fallback)
|
||||
|
||||
2. **Email Not Sending**:
|
||||
- Verify SMTP credentials in .env
|
||||
- Check spam/junk folders
|
||||
- Review email logs via API endpoint
|
||||
- In development: Check MailHog UI at http://localhost:8025
|
||||
- Use email testing interface at `/email-test.html` for diagnostics
|
||||
|
||||
3. **No Representatives Found**:
|
||||
- Ensure postal code starts with 'T' (Alberta)
|
||||
- Check Represent API status
|
||||
- Try different postal code format
|
||||
|
||||
### Log Analysis
|
||||
```bash
|
||||
# View application logs
|
||||
docker compose logs app
|
||||
|
||||
# Follow logs in real-time
|
||||
docker compose logs -f app
|
||||
|
||||
# Check specific errors
|
||||
docker compose logs app | grep ERROR
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
This is part of the larger changemaker.lite project. Follow the established patterns for:
|
||||
- Error handling and logging
|
||||
- API response formats
|
||||
- Database integration
|
||||
- Frontend component structure
|
||||
|
||||
## License
|
||||
|
||||
Part of the changemaker.lite project ecosystem.
|
||||
|
||||
@ -1,21 +0,0 @@
|
||||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# Install curl for healthcheck
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install --only=production
|
||||
|
||||
# Copy app files
|
||||
COPY . .
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Start the application
|
||||
CMD ["node", "server.js"]
|
||||
@ -1,264 +0,0 @@
|
||||
const nocodbService = require('../services/nocodb');
|
||||
|
||||
class AuthController {
|
||||
async login(req, res) {
|
||||
try {
|
||||
const { email, password } = req.body;
|
||||
|
||||
// Validate input
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Email and password are required'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid email format'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Login attempt:', {
|
||||
email,
|
||||
ip: req.ip,
|
||||
userAgent: req.headers['user-agent']
|
||||
});
|
||||
|
||||
// Fetch user from NocoDB
|
||||
const user = await nocodbService.getUserByEmail(email);
|
||||
|
||||
if (!user) {
|
||||
console.warn(`No user found with email: ${email}`);
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: 'Invalid email or password'
|
||||
});
|
||||
}
|
||||
|
||||
// Check password
|
||||
if (user.Password !== password && user.password !== password) {
|
||||
console.warn(`Invalid password for email: ${email}`);
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: 'Invalid email or password'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if temp user has expired
|
||||
const userType = user['User Type'] || user.UserType || user.userType || 'user';
|
||||
if (userType === 'temp') {
|
||||
const expiration = user.ExpiresAt || user.expiresAt || user.Expiration || user.expiration;
|
||||
if (expiration) {
|
||||
const expirationDate = new Date(expiration);
|
||||
const now = new Date();
|
||||
|
||||
if (now > expirationDate) {
|
||||
console.warn(`Expired temp user attempted login: ${email}, expired: ${expiration}`);
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: 'Account has expired. Please contact an administrator.'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update last login time
|
||||
try {
|
||||
// Debug: Log user object structure
|
||||
console.log('User object keys:', Object.keys(user));
|
||||
console.log('User ID candidates:', {
|
||||
ID: user.ID,
|
||||
Id: user.Id,
|
||||
id: user.id
|
||||
});
|
||||
|
||||
const userId = user.ID || user.Id || user.id;
|
||||
|
||||
if (userId) {
|
||||
await nocodbService.updateUser(userId, {
|
||||
'Last Login': new Date().toISOString()
|
||||
});
|
||||
} else {
|
||||
console.warn('No valid user ID found for updating last login time');
|
||||
}
|
||||
} catch (updateError) {
|
||||
console.warn('Failed to update last login time:', updateError.message);
|
||||
// Don't fail the login
|
||||
}
|
||||
|
||||
// Set session
|
||||
req.session.authenticated = true;
|
||||
req.session.userId = user.ID || user.Id || user.id;
|
||||
req.session.userEmail = user.Email || user.email;
|
||||
req.session.userName = user.Name || user.name;
|
||||
req.session.isAdmin = user.Admin || user.admin || false;
|
||||
req.session.userType = userType;
|
||||
|
||||
console.log('User logged in successfully:', {
|
||||
email: req.session.userEmail,
|
||||
isAdmin: req.session.isAdmin
|
||||
});
|
||||
|
||||
// Force session save
|
||||
req.session.save((err) => {
|
||||
if (err) {
|
||||
console.error('Session save error:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Session error. Please try again.'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
user: {
|
||||
id: req.session.userId,
|
||||
email: req.session.userEmail,
|
||||
name: req.session.userName,
|
||||
isAdmin: req.session.isAdmin,
|
||||
userType: req.session.userType
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Server error. Please try again later.'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async logout(req, res) {
|
||||
try {
|
||||
const userEmail = req.session?.userEmail;
|
||||
|
||||
req.session.destroy((err) => {
|
||||
if (err) {
|
||||
console.error('Session destroy error:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Logout failed'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('User logged out:', userEmail);
|
||||
res.json({ success: true });
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Server error during logout'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async checkSession(req, res) {
|
||||
try {
|
||||
const isAuthenticated = (req.session && req.session.authenticated) ||
|
||||
(req.session && req.session.userId && req.session.userEmail);
|
||||
|
||||
if (isAuthenticated) {
|
||||
res.json({
|
||||
authenticated: true,
|
||||
user: {
|
||||
id: req.session.userId,
|
||||
email: req.session.userEmail,
|
||||
name: req.session.userName,
|
||||
isAdmin: req.session.isAdmin,
|
||||
userType: req.session.userType || 'user'
|
||||
}
|
||||
});
|
||||
} else {
|
||||
res.json({
|
||||
authenticated: false
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Session check error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Session check failed'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async changePassword(req, res) {
|
||||
try {
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
|
||||
// Validate input
|
||||
if (!currentPassword || !newPassword) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Current password and new password are required'
|
||||
});
|
||||
}
|
||||
|
||||
// Validate new password strength
|
||||
if (newPassword.length < 8) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'New password must be at least 8 characters long'
|
||||
});
|
||||
}
|
||||
|
||||
// Get user from session
|
||||
const userId = req.session.userId;
|
||||
const userEmail = req.session.userEmail;
|
||||
|
||||
if (!userId || !userEmail) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: 'Session expired. Please login again.'
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch user from NocoDB to verify current password
|
||||
const user = await nocodbService.getUserByEmail(userEmail);
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'User not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Verify current password
|
||||
const storedPassword = user.Password || user.password;
|
||||
if (storedPassword !== currentPassword) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: 'Current password is incorrect'
|
||||
});
|
||||
}
|
||||
|
||||
// Update password in NocoDB
|
||||
await nocodbService.updateUser(userId, {
|
||||
Password: newPassword
|
||||
});
|
||||
|
||||
console.log('Password changed successfully for user:', userEmail);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Password changed successfully'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Change password error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to change password. Please try again later.'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new AuthController();
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,283 +0,0 @@
|
||||
const nocoDB = require('../services/nocodb');
|
||||
const { validateEmail } = require('../utils/validators');
|
||||
|
||||
class CustomRecipientsController {
|
||||
/**
|
||||
* Get all custom recipients for a campaign
|
||||
*/
|
||||
async getRecipientsByCampaign(req, res, next) {
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
|
||||
// Get campaign first to verify it exists and get ID
|
||||
const campaign = await nocoDB.getCampaignBySlug(slug);
|
||||
if (!campaign) {
|
||||
return res.status(404).json({ error: 'Campaign not found' });
|
||||
}
|
||||
|
||||
// Check if custom recipients are enabled for this campaign
|
||||
// Use NocoDB column title, not camelCase
|
||||
if (!campaign['Allow Custom Recipients']) {
|
||||
return res.json({ recipients: [], message: 'Custom recipients not enabled for this campaign' });
|
||||
}
|
||||
|
||||
// Get custom recipients for this campaign using slug
|
||||
const recipients = await nocoDB.getCustomRecipientsBySlug(slug);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
recipients: recipients || [],
|
||||
count: recipients ? recipients.length : 0
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching custom recipients:', error);
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single custom recipient
|
||||
*/
|
||||
async createRecipient(req, res, next) {
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
const { recipient_name, recipient_email, recipient_title, recipient_organization, notes } = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!recipient_name || !recipient_email) {
|
||||
return res.status(400).json({ error: 'Recipient name and email are required' });
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
if (!validateEmail(recipient_email)) {
|
||||
return res.status(400).json({ error: 'Invalid email format' });
|
||||
}
|
||||
|
||||
// Get campaign to verify it exists and get ID
|
||||
const campaign = await nocoDB.getCampaignBySlug(slug);
|
||||
if (!campaign) {
|
||||
return res.status(404).json({ error: 'Campaign not found' });
|
||||
}
|
||||
|
||||
// Check if custom recipients are enabled for this campaign
|
||||
// Use NocoDB column title, not camelCase field name
|
||||
if (!campaign['Allow Custom Recipients']) {
|
||||
console.warn('Custom recipients not enabled. Campaign data:', campaign);
|
||||
return res.status(403).json({ error: 'Custom recipients not enabled for this campaign' });
|
||||
}
|
||||
|
||||
// Create the recipient
|
||||
// Use campaign.ID (NocoDB system field) not campaign.id
|
||||
const recipientData = {
|
||||
campaign_id: campaign.ID,
|
||||
campaign_slug: slug,
|
||||
recipient_name,
|
||||
recipient_email,
|
||||
recipient_title: recipient_title || null,
|
||||
recipient_organization: recipient_organization || null,
|
||||
notes: notes || null,
|
||||
is_active: true
|
||||
};
|
||||
|
||||
const newRecipient = await nocoDB.createCustomRecipient(recipientData);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
recipient: newRecipient,
|
||||
message: 'Recipient created successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating custom recipient:', error);
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk create custom recipients
|
||||
*/
|
||||
async bulkCreateRecipients(req, res, next) {
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
const { recipients } = req.body;
|
||||
|
||||
// Validate input
|
||||
if (!Array.isArray(recipients) || recipients.length === 0) {
|
||||
return res.status(400).json({ error: 'Recipients array is required and must not be empty' });
|
||||
}
|
||||
|
||||
// Get campaign to verify it exists and get ID
|
||||
const campaign = await nocoDB.getCampaignBySlug(slug);
|
||||
if (!campaign) {
|
||||
return res.status(404).json({ error: 'Campaign not found' });
|
||||
}
|
||||
|
||||
// Check if custom recipients are enabled for this campaign
|
||||
if (!campaign.allow_custom_recipients) {
|
||||
return res.status(403).json({ error: 'Custom recipients not enabled for this campaign' });
|
||||
}
|
||||
|
||||
const results = {
|
||||
success: [],
|
||||
failed: [],
|
||||
total: recipients.length
|
||||
};
|
||||
|
||||
// Process each recipient
|
||||
for (const recipient of recipients) {
|
||||
try {
|
||||
// Validate required fields
|
||||
if (!recipient.recipient_name || !recipient.recipient_email) {
|
||||
results.failed.push({
|
||||
recipient,
|
||||
error: 'Missing required fields (name or email)'
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate email format
|
||||
if (!validateEmail(recipient.recipient_email)) {
|
||||
results.failed.push({
|
||||
recipient,
|
||||
error: 'Invalid email format'
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create the recipient
|
||||
const recipientData = {
|
||||
campaign_id: campaign.id,
|
||||
campaign_slug: slug,
|
||||
recipient_name: recipient.recipient_name,
|
||||
recipient_email: recipient.recipient_email,
|
||||
recipient_title: recipient.recipient_title || null,
|
||||
recipient_organization: recipient.recipient_organization || null,
|
||||
notes: recipient.notes || null,
|
||||
is_active: true
|
||||
};
|
||||
|
||||
const newRecipient = await nocoDB.createCustomRecipient(recipientData);
|
||||
results.success.push(newRecipient);
|
||||
} catch (error) {
|
||||
results.failed.push({
|
||||
recipient,
|
||||
error: error.message || 'Unknown error'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
results,
|
||||
message: `Successfully created ${results.success.length} of ${results.total} recipients`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error bulk creating custom recipients:', error);
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a custom recipient
|
||||
*/
|
||||
async updateRecipient(req, res, next) {
|
||||
try {
|
||||
const { slug, id } = req.params;
|
||||
const { recipient_name, recipient_email, recipient_title, recipient_organization, notes, is_active } = req.body;
|
||||
|
||||
// Validate email if provided
|
||||
if (recipient_email && !validateEmail(recipient_email)) {
|
||||
return res.status(400).json({ error: 'Invalid email format' });
|
||||
}
|
||||
|
||||
// Get campaign to verify it exists
|
||||
const campaign = await nocoDB.getCampaignBySlug(slug);
|
||||
if (!campaign) {
|
||||
return res.status(404).json({ error: 'Campaign not found' });
|
||||
}
|
||||
|
||||
// Build update data (only include provided fields)
|
||||
const updateData = {};
|
||||
if (recipient_name !== undefined) updateData.recipient_name = recipient_name;
|
||||
if (recipient_email !== undefined) updateData.recipient_email = recipient_email;
|
||||
if (recipient_title !== undefined) updateData.recipient_title = recipient_title;
|
||||
if (recipient_organization !== undefined) updateData.recipient_organization = recipient_organization;
|
||||
if (notes !== undefined) updateData.notes = notes;
|
||||
if (is_active !== undefined) updateData.is_active = is_active;
|
||||
|
||||
// Update the recipient
|
||||
const updatedRecipient = await nocoDB.updateCustomRecipient(id, updateData);
|
||||
|
||||
if (!updatedRecipient) {
|
||||
return res.status(404).json({ error: 'Recipient not found' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
recipient: updatedRecipient,
|
||||
message: 'Recipient updated successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating custom recipient:', error);
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a custom recipient
|
||||
*/
|
||||
async deleteRecipient(req, res, next) {
|
||||
try {
|
||||
const { slug, id } = req.params;
|
||||
|
||||
// Get campaign to verify it exists
|
||||
const campaign = await nocoDB.getCampaignBySlug(slug);
|
||||
if (!campaign) {
|
||||
return res.status(404).json({ error: 'Campaign not found' });
|
||||
}
|
||||
|
||||
// Delete the recipient
|
||||
const deleted = await nocoDB.deleteCustomRecipient(id);
|
||||
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: 'Recipient not found' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Recipient deleted successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting custom recipient:', error);
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all custom recipients for a campaign
|
||||
*/
|
||||
async deleteAllRecipients(req, res, next) {
|
||||
try {
|
||||
const { slug } = req.params;
|
||||
|
||||
// Get campaign to verify it exists and get ID
|
||||
const campaign = await nocoDB.getCampaignBySlug(slug);
|
||||
if (!campaign) {
|
||||
return res.status(404).json({ error: 'Campaign not found' });
|
||||
}
|
||||
|
||||
// Delete all recipients for this campaign
|
||||
const deletedCount = await nocoDB.deleteCustomRecipientsByCampaign(campaign.id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
deletedCount,
|
||||
message: `Successfully deleted ${deletedCount} recipient(s)`
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting all custom recipients:', error);
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new CustomRecipientsController();
|
||||
@ -1,417 +0,0 @@
|
||||
const emailService = require('../services/email');
|
||||
const nocoDB = require('../services/nocodb');
|
||||
const crypto = require('crypto');
|
||||
|
||||
class EmailsController {
|
||||
async sendEmail(req, res, next) {
|
||||
try {
|
||||
const { recipientEmail, senderName, senderEmail, subject, message, postalCode, recipientName } = req.body;
|
||||
|
||||
// Send the email using template system
|
||||
const emailResult = await emailService.sendRepresentativeEmail(
|
||||
recipientEmail,
|
||||
senderName,
|
||||
senderEmail,
|
||||
subject,
|
||||
message,
|
||||
postalCode,
|
||||
recipientName
|
||||
);
|
||||
|
||||
// Log the email send event
|
||||
await nocoDB.logEmailSend({
|
||||
recipientEmail,
|
||||
senderName,
|
||||
senderEmail,
|
||||
subject,
|
||||
message,
|
||||
postalCode,
|
||||
status: emailResult.success ? 'sent' : 'failed',
|
||||
timestamp: new Date().toISOString(),
|
||||
senderIP: req.ip || req.connection.remoteAddress
|
||||
});
|
||||
|
||||
if (emailResult.success) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Email sent successfully',
|
||||
messageId: emailResult.messageId
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to send email',
|
||||
message: emailResult.error
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Send email error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to send email',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async previewEmail(req, res, next) {
|
||||
try {
|
||||
const { recipientEmail, subject, message, senderName, senderEmail, postalCode, recipientName } = req.body;
|
||||
|
||||
const templateVariables = {
|
||||
MESSAGE: message,
|
||||
SENDER_NAME: senderName || 'Anonymous',
|
||||
SENDER_EMAIL: senderEmail || 'unknown@example.com',
|
||||
POSTAL_CODE: postalCode || 'Unknown',
|
||||
RECIPIENT_NAME: recipientName || 'Representative'
|
||||
};
|
||||
|
||||
const emailOptions = {
|
||||
to: recipientEmail,
|
||||
from: {
|
||||
email: process.env.SMTP_FROM_EMAIL,
|
||||
name: process.env.SMTP_FROM_NAME
|
||||
},
|
||||
replyTo: senderEmail || process.env.SMTP_FROM_EMAIL,
|
||||
subject: subject
|
||||
};
|
||||
|
||||
const preview = await emailService.previewTemplatedEmail('representative-contact', templateVariables, emailOptions);
|
||||
|
||||
// Log the email preview event (non-blocking)
|
||||
try {
|
||||
await nocoDB.logEmailPreview({
|
||||
recipientEmail,
|
||||
senderName,
|
||||
senderEmail,
|
||||
subject,
|
||||
message,
|
||||
postalCode,
|
||||
timestamp: new Date().toISOString(),
|
||||
senderIP: req.ip || req.connection.remoteAddress
|
||||
});
|
||||
} catch (loggingError) {
|
||||
console.error('Failed to log email preview:', loggingError);
|
||||
// Don't fail the preview if logging fails
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
preview: preview,
|
||||
html: emailOptions.html
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Email preview error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to generate email preview',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async sendTestEmail(req, res, next) {
|
||||
try {
|
||||
const { subject, message } = req.body;
|
||||
|
||||
const testRecipient = process.env.TEST_EMAIL_RECIPIENT || req.user?.email || 'admin@example.com';
|
||||
|
||||
const emailResult = await emailService.sendTestEmail(subject, message, testRecipient);
|
||||
|
||||
if (emailResult.success) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Test email sent successfully',
|
||||
messageId: emailResult.messageId,
|
||||
sentTo: testRecipient,
|
||||
testMode: emailResult.testMode
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to send test email',
|
||||
message: emailResult.error
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Send test email error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to send test email',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getEmailLogs(req, res, next) {
|
||||
try {
|
||||
const { status, senderEmail, postalCode } = req.query;
|
||||
|
||||
if (!process.env.NOCODB_TABLE_EMAILS) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Email logging not configured'
|
||||
});
|
||||
}
|
||||
|
||||
const filters = {};
|
||||
if (status) filters.status = status;
|
||||
if (senderEmail) filters.senderEmail = senderEmail;
|
||||
if (postalCode) filters.postalCode = postalCode;
|
||||
|
||||
const logs = await nocoDB.getEmailLogs(filters);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
logs: logs || []
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get email logs error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to retrieve email logs',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async testSMTPConnection(req, res, next) {
|
||||
try {
|
||||
const testResult = await emailService.testConnection();
|
||||
|
||||
res.json({
|
||||
success: testResult.success,
|
||||
message: testResult.message,
|
||||
error: testResult.error
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('SMTP test error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to test SMTP connection',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async testSMTPConnection(req, res, next) {
|
||||
try {
|
||||
const result = await emailService.testConnection();
|
||||
|
||||
res.json({
|
||||
success: result.success,
|
||||
message: result.message,
|
||||
error: result.error
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('SMTP test error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to test SMTP connection',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async initiateEmailToCampaign(req, res, next) {
|
||||
try {
|
||||
const { email, subject, message, postalCode, senderName } = req.body;
|
||||
|
||||
// Check if email verification is enabled
|
||||
const verificationEnabled = process.env.EMAIL_VERIFICATION_ENABLED !== 'false';
|
||||
if (!verificationEnabled) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Email verification is not enabled'
|
||||
});
|
||||
}
|
||||
|
||||
// Generate verification token
|
||||
const token = crypto.randomBytes(32).toString('hex');
|
||||
const expiryHours = parseInt(process.env.EMAIL_VERIFICATION_EXPIRY) || 24;
|
||||
const expiresAt = new Date(Date.now() + expiryHours * 60 * 60 * 1000);
|
||||
|
||||
// Store token and campaign data
|
||||
await nocoDB.createEmailVerification({
|
||||
token,
|
||||
email,
|
||||
temp_campaign_data: JSON.stringify({
|
||||
subject,
|
||||
message,
|
||||
postalCode,
|
||||
senderName
|
||||
}),
|
||||
created_at: new Date().toISOString(),
|
||||
expires_at: expiresAt.toISOString(),
|
||||
used: false
|
||||
});
|
||||
|
||||
// Send verification email
|
||||
const appUrl = process.env.APP_URL || 'http://localhost:3333';
|
||||
const verificationUrl = `${appUrl}/verify-email.html?token=${token}`;
|
||||
await emailService.sendEmailVerification(email, verificationUrl, senderName || 'there');
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Verification email sent. Please check your inbox.'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Email to campaign conversion error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to initiate campaign conversion',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async verifyEmailToken(req, res, next) {
|
||||
try {
|
||||
const { token } = req.params;
|
||||
|
||||
// Find verification record
|
||||
const verification = await nocoDB.getEmailVerificationByToken(token);
|
||||
|
||||
if (!verification) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Invalid or expired verification link'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if already used
|
||||
if (verification.used) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'This verification link has already been used'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(verification.expires_at || verification.expiresAt || verification['Expires At']);
|
||||
if (now > expiresAt) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'This verification link has expired'
|
||||
});
|
||||
}
|
||||
|
||||
// Mark as used
|
||||
const verificationId = verification.id || verification.Id || verification.ID;
|
||||
await nocoDB.updateEmailVerification(verificationId, { used: true });
|
||||
|
||||
// Parse campaign data
|
||||
const campaignDataStr = verification.temp_campaign_data || verification.tempCampaignData || verification['Temp Campaign Data'];
|
||||
const campaignData = JSON.parse(campaignDataStr);
|
||||
|
||||
// Check if user exists
|
||||
const userEmail = verification.email || verification.Email;
|
||||
const existingUser = await nocoDB.getUserByEmail(userEmail);
|
||||
|
||||
if (existingUser) {
|
||||
// User exists, log them in automatically
|
||||
req.session.authenticated = true;
|
||||
req.session.userId = existingUser.ID || existingUser.Id || existingUser.id;
|
||||
req.session.userEmail = existingUser.Email || existingUser.email;
|
||||
req.session.userName = existingUser.Name || existingUser.name;
|
||||
req.session.isAdmin = existingUser.Admin || existingUser.admin || false;
|
||||
req.session.userType = existingUser['User Type'] || existingUser.UserType || existingUser.userType || 'user';
|
||||
|
||||
req.session.save((err) => {
|
||||
if (err) {
|
||||
console.error('Session save error:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Session error'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
needsAccount: false,
|
||||
campaignData: campaignData,
|
||||
redirectTo: '/dashboard.html'
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// User doesn't exist - create a new user account automatically
|
||||
try {
|
||||
// Generate a temporary password (user can change it later)
|
||||
const tempPassword = crypto.randomBytes(16).toString('hex');
|
||||
|
||||
// Extract name from campaign data or use email prefix
|
||||
const userName = campaignData.senderName || userEmail.split('@')[0];
|
||||
|
||||
// Create new user
|
||||
const newUser = await nocoDB.createUser({
|
||||
'Name': userName,
|
||||
'Email': userEmail,
|
||||
'Password': tempPassword,
|
||||
'Admin': false,
|
||||
'User Type': 'user'
|
||||
});
|
||||
|
||||
const userId = newUser.ID || newUser.Id || newUser.id || newUser;
|
||||
|
||||
// Send login credentials email to the new user
|
||||
try {
|
||||
await emailService.sendLoginDetails({
|
||||
Name: userName,
|
||||
Email: userEmail,
|
||||
Password: tempPassword,
|
||||
admin: false
|
||||
});
|
||||
console.log('Welcome email with credentials sent to:', userEmail);
|
||||
} catch (emailError) {
|
||||
console.error('Failed to send welcome email:', emailError);
|
||||
// Don't fail the whole process if email sending fails
|
||||
}
|
||||
|
||||
// Log the new user in automatically
|
||||
req.session.authenticated = true;
|
||||
req.session.userId = userId;
|
||||
req.session.userEmail = userEmail;
|
||||
req.session.userName = userName;
|
||||
req.session.isAdmin = false;
|
||||
req.session.userType = 'user';
|
||||
|
||||
req.session.save((err) => {
|
||||
if (err) {
|
||||
console.error('Session save error:', err);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Session error'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
needsAccount: false,
|
||||
isNewUser: true,
|
||||
campaignData: campaignData,
|
||||
redirectTo: '/dashboard.html',
|
||||
message: 'Account created successfully! Check your email for login credentials.'
|
||||
});
|
||||
});
|
||||
} catch (createError) {
|
||||
console.error('Error creating user account:', createError);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to create user account',
|
||||
message: createError.message
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Email verification error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to verify email',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new EmailsController();
|
||||
@ -1,262 +0,0 @@
|
||||
const listmonkService = require('../services/listmonk');
|
||||
const nocodbService = require('../services/nocodb');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// Get Listmonk sync status
|
||||
exports.getSyncStatus = async (req, res) => {
|
||||
try {
|
||||
const status = listmonkService.getSyncStatus();
|
||||
|
||||
// Also check connection if it's enabled
|
||||
if (status.enabled && !status.connected) {
|
||||
// Try to reconnect
|
||||
const reconnected = await listmonkService.checkConnection();
|
||||
status.connected = reconnected;
|
||||
}
|
||||
|
||||
res.json(status);
|
||||
} catch (error) {
|
||||
logger.error('Failed to get Listmonk status', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to get sync status'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Sync all campaign participants to Listmonk
|
||||
exports.syncCampaignParticipants = async (req, res) => {
|
||||
try {
|
||||
if (!listmonkService.syncEnabled) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Listmonk sync is disabled'
|
||||
});
|
||||
}
|
||||
|
||||
// Get all campaign emails (use campaignEmails table, not emails)
|
||||
const emailsData = await nocodbService.getAll(nocodbService.tableIds.campaignEmails);
|
||||
const emails = emailsData?.list || [];
|
||||
|
||||
// Get all campaigns for reference
|
||||
const campaigns = await nocodbService.getAllCampaigns();
|
||||
|
||||
if (!emails || emails.length === 0) {
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'No campaign participants to sync',
|
||||
results: { total: 0, success: 0, failed: 0, errors: [] }
|
||||
});
|
||||
}
|
||||
|
||||
const results = await listmonkService.bulkSyncCampaignParticipants(emails, campaigns);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Campaign participants sync completed: ${results.success} succeeded, ${results.failed} failed`,
|
||||
results
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Campaign participants sync failed', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to sync campaign participants to Listmonk'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Sync all custom recipients to Listmonk
|
||||
exports.syncCustomRecipients = async (req, res) => {
|
||||
try {
|
||||
if (!listmonkService.syncEnabled) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Listmonk sync is disabled'
|
||||
});
|
||||
}
|
||||
|
||||
// Get all custom recipients
|
||||
const recipientsData = await nocodbService.getAll(nocodbService.tableIds.customRecipients);
|
||||
const recipients = recipientsData?.list || [];
|
||||
|
||||
// Get all campaigns for reference
|
||||
const campaigns = await nocodbService.getAllCampaigns();
|
||||
|
||||
if (!recipients || recipients.length === 0) {
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'No custom recipients to sync',
|
||||
results: { total: 0, success: 0, failed: 0, errors: [] }
|
||||
});
|
||||
}
|
||||
|
||||
const results = await listmonkService.bulkSyncCustomRecipients(recipients, campaigns);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Custom recipients sync completed: ${results.success} succeeded, ${results.failed} failed`,
|
||||
results
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Custom recipients sync failed', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to sync custom recipients to Listmonk'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Sync everything (participants and custom recipients)
|
||||
exports.syncAll = async (req, res) => {
|
||||
try {
|
||||
if (!listmonkService.syncEnabled) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Listmonk sync is disabled'
|
||||
});
|
||||
}
|
||||
|
||||
let results = {
|
||||
participants: { total: 0, success: 0, failed: 0, errors: [] },
|
||||
customRecipients: { total: 0, success: 0, failed: 0, errors: [] }
|
||||
};
|
||||
|
||||
// Get campaigns once for both syncs
|
||||
const campaigns = await nocodbService.getAllCampaigns();
|
||||
|
||||
// Sync campaign participants
|
||||
try {
|
||||
const emailsData = await nocodbService.getAll(nocodbService.tableIds.campaignEmails);
|
||||
const emails = emailsData?.list || [];
|
||||
if (emails && emails.length > 0) {
|
||||
results.participants = await listmonkService.bulkSyncCampaignParticipants(emails, campaigns);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync campaign participants during full sync', error);
|
||||
results.participants.errors.push({ error: error.message });
|
||||
}
|
||||
|
||||
// Sync custom recipients
|
||||
try {
|
||||
const recipientsData = await nocodbService.getAll(nocodbService.tableIds.customRecipients);
|
||||
const recipients = recipientsData?.list || [];
|
||||
if (recipients && recipients.length > 0) {
|
||||
results.customRecipients = await listmonkService.bulkSyncCustomRecipients(recipients, campaigns);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to sync custom recipients during full sync', error);
|
||||
results.customRecipients.errors.push({ error: error.message });
|
||||
}
|
||||
|
||||
const totalSuccess = results.participants.success + results.customRecipients.success;
|
||||
const totalFailed = results.participants.failed + results.customRecipients.failed;
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Complete sync finished: ${totalSuccess} succeeded, ${totalFailed} failed`,
|
||||
results
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Complete sync failed', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to perform complete sync'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Get Listmonk list statistics
|
||||
exports.getListStats = async (req, res) => {
|
||||
try {
|
||||
if (!listmonkService.syncEnabled) {
|
||||
return res.json({
|
||||
success: false,
|
||||
error: 'Listmonk sync is disabled',
|
||||
stats: null
|
||||
});
|
||||
}
|
||||
|
||||
const stats = await listmonkService.getListStats();
|
||||
|
||||
// Convert stats object to array format for frontend
|
||||
let statsArray = [];
|
||||
if (stats && typeof stats === 'object') {
|
||||
statsArray = Object.entries(stats).map(([key, list]) => ({
|
||||
id: key,
|
||||
name: list.name,
|
||||
subscriberCount: list.subscriber_count || 0,
|
||||
description: `Email list for ${key}`
|
||||
}));
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
stats: statsArray
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to get Listmonk list stats', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to get list statistics'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Test Listmonk connection
|
||||
exports.testConnection = async (req, res) => {
|
||||
try {
|
||||
const connected = await listmonkService.checkConnection();
|
||||
|
||||
if (connected) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Listmonk connection successful',
|
||||
connected: true
|
||||
});
|
||||
} else {
|
||||
res.json({
|
||||
success: false,
|
||||
message: listmonkService.lastError || 'Connection failed',
|
||||
connected: false
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to test Listmonk connection', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to test connection'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Reinitialize Listmonk lists
|
||||
exports.reinitializeLists = async (req, res) => {
|
||||
try {
|
||||
if (!listmonkService.syncEnabled) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Listmonk sync is disabled'
|
||||
});
|
||||
}
|
||||
|
||||
const initialized = await listmonkService.initializeLists();
|
||||
|
||||
if (initialized) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Listmonk lists reinitialized successfully'
|
||||
});
|
||||
} else {
|
||||
res.json({
|
||||
success: false,
|
||||
message: listmonkService.lastError || 'Failed to initialize lists'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to reinitialize Listmonk lists', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to reinitialize lists'
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -1,282 +0,0 @@
|
||||
const representAPI = require('../services/represent-api');
|
||||
const nocoDB = require('../services/nocodb');
|
||||
|
||||
// Helper function to cache representatives
|
||||
async function cacheRepresentatives(postalCode, representatives, representData) {
|
||||
try {
|
||||
// Cache the postal code info
|
||||
await nocoDB.storePostalCodeInfo({
|
||||
postal_code: postalCode,
|
||||
city: representData.city,
|
||||
province: representData.province
|
||||
});
|
||||
|
||||
// Cache representatives using the existing method
|
||||
const result = await nocoDB.storeRepresentatives(postalCode, representatives);
|
||||
|
||||
if (result.success) {
|
||||
console.log(`Successfully cached ${result.count} representatives for ${postalCode}`);
|
||||
} else {
|
||||
console.log(`Partial success caching representatives for ${postalCode}: ${result.error || 'unknown error'}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Failed to cache representatives for ${postalCode}:`, error.message);
|
||||
// Don't throw - caching is optional and should never break the main flow
|
||||
}
|
||||
}
|
||||
|
||||
class RepresentativesController {
|
||||
async testConnection(req, res, next) {
|
||||
try {
|
||||
const result = await representAPI.testConnection();
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Test connection error:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to test connection',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async getByPostalCode(req, res, next) {
|
||||
try {
|
||||
const { postalCode } = req.params;
|
||||
const formattedPostalCode = postalCode.replace(/\s/g, '').toUpperCase();
|
||||
|
||||
// Try to check cached data first, but don't fail if NocoDB is down
|
||||
let cachedData = [];
|
||||
try {
|
||||
cachedData = await nocoDB.getRepresentativesByPostalCode(formattedPostalCode);
|
||||
console.log(`Cache check for ${formattedPostalCode}: found ${cachedData.length} records`);
|
||||
|
||||
if (cachedData && cachedData.length > 0) {
|
||||
return res.json({
|
||||
success: true,
|
||||
source: 'Local Cache',
|
||||
data: {
|
||||
postalCode: formattedPostalCode,
|
||||
location: {
|
||||
city: cachedData[0]?.city || 'Alberta',
|
||||
province: 'AB'
|
||||
},
|
||||
representatives: cachedData
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (cacheError) {
|
||||
console.log(`Cache unavailable for ${formattedPostalCode}, proceeding with API call:`, cacheError.message);
|
||||
}
|
||||
|
||||
// If not in cache, fetch from Represent API
|
||||
console.log(`Fetching representatives from Represent API for ${postalCode}`);
|
||||
const representData = await representAPI.getRepresentativesByPostalCode(postalCode);
|
||||
|
||||
if (!representData) {
|
||||
return res.json({
|
||||
success: false,
|
||||
message: 'No data found for this postal code',
|
||||
data: {
|
||||
postalCode,
|
||||
location: null,
|
||||
representatives: []
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Process representatives from both concordance and centroid
|
||||
let representatives = [];
|
||||
|
||||
// Add concordance representatives (if any)
|
||||
if (representData.boundaries_concordance && representData.boundaries_concordance.length > 0) {
|
||||
representatives = representatives.concat(representData.boundaries_concordance);
|
||||
}
|
||||
|
||||
// Add centroid representatives (if any) - these are the actual elected officials
|
||||
if (representData.representatives_centroid && representData.representatives_centroid.length > 0) {
|
||||
representatives = representatives.concat(representData.representatives_centroid);
|
||||
}
|
||||
|
||||
// Representatives already include office information, no need for additional API calls
|
||||
console.log('Using representatives data with existing office information');
|
||||
|
||||
console.log(`Representatives concordance count: ${representData.boundaries_concordance ? representData.boundaries_concordance.length : 0}`);
|
||||
console.log(`Representatives centroid count: ${representData.representatives_centroid ? representData.representatives_centroid.length : 0}`);
|
||||
console.log(`Total representatives found: ${representatives.length}`);
|
||||
|
||||
if (representatives.length === 0) {
|
||||
return res.json({
|
||||
success: false,
|
||||
message: 'No representatives found for this postal code',
|
||||
data: {
|
||||
postalCode,
|
||||
location: {
|
||||
city: representData.city,
|
||||
province: representData.province
|
||||
},
|
||||
representatives: []
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Try to cache the results (will fail gracefully if NocoDB is down)
|
||||
console.log(`Attempting to cache ${representatives.length} representatives for ${postalCode}`);
|
||||
await cacheRepresentatives(postalCode, representatives, representData);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
source: 'Open North',
|
||||
data: {
|
||||
postalCode,
|
||||
location: {
|
||||
city: representData.city,
|
||||
province: representData.province
|
||||
},
|
||||
representatives
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get representatives error:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to fetch representatives',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async refreshPostalCode(req, res, next) {
|
||||
try {
|
||||
const { postalCode } = req.params;
|
||||
const formattedPostalCode = postalCode.replace(/\s/g, '').toUpperCase();
|
||||
|
||||
// Clear cached data
|
||||
await nocoDB.clearRepresentativesByPostalCode(formattedPostalCode);
|
||||
|
||||
// Fetch fresh data from API
|
||||
const representData = await representAPI.getRepresentativesByPostalCode(formattedPostalCode);
|
||||
|
||||
if (!representData || !representData.representatives_concordance) {
|
||||
return res.status(404).json({
|
||||
error: 'No representatives found for this postal code',
|
||||
postalCode: formattedPostalCode
|
||||
});
|
||||
}
|
||||
|
||||
// Cache the fresh results
|
||||
await nocoDB.storeRepresentatives(formattedPostalCode, representData.representatives_concordance);
|
||||
|
||||
res.json({
|
||||
source: 'Open North',
|
||||
postalCode: formattedPostalCode,
|
||||
representatives: representData.representatives_concordance,
|
||||
city: representData.city,
|
||||
province: representData.province
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Refresh representatives error:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to refresh representatives',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async trackCall(req, res, next) {
|
||||
try {
|
||||
const {
|
||||
representativeName,
|
||||
representativeTitle,
|
||||
phoneNumber,
|
||||
officeType,
|
||||
userEmail,
|
||||
userName,
|
||||
postalCode
|
||||
} = req.body;
|
||||
|
||||
// Validate required fields
|
||||
if (!representativeName || !phoneNumber) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Representative name and phone number are required'
|
||||
});
|
||||
}
|
||||
|
||||
// Log the call
|
||||
await nocoDB.logCall({
|
||||
representativeName,
|
||||
representativeTitle: representativeTitle || null,
|
||||
phoneNumber,
|
||||
officeType: officeType || null,
|
||||
callerName: userName || null,
|
||||
callerEmail: userEmail || null,
|
||||
postalCode: postalCode || null,
|
||||
campaignId: null,
|
||||
campaignSlug: null,
|
||||
callerIP: req.ip || req.connection?.remoteAddress || null,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Call tracked successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Track call error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to track call',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async geocodeAddress(req, res, next) {
|
||||
try {
|
||||
const { address } = req.body;
|
||||
const axios = require('axios');
|
||||
|
||||
console.log(`Geocoding address: ${address}`);
|
||||
|
||||
// Use Nominatim API (OpenStreetMap)
|
||||
const encodedAddress = encodeURIComponent(address);
|
||||
const url = `https://nominatim.openstreetmap.org/search?format=json&q=${encodedAddress}&limit=1&countrycodes=ca`;
|
||||
|
||||
const response = await axios.get(url, {
|
||||
headers: {
|
||||
'User-Agent': 'BNKops-Influence-Tool/1.0'
|
||||
},
|
||||
timeout: 5000
|
||||
});
|
||||
|
||||
if (response.data && response.data.length > 0 && response.data[0].lat && response.data[0].lon) {
|
||||
const result = {
|
||||
lat: parseFloat(response.data[0].lat),
|
||||
lng: parseFloat(response.data[0].lon),
|
||||
display_name: response.data[0].display_name
|
||||
};
|
||||
|
||||
console.log(`Geocoded "${address}" to:`, result);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
});
|
||||
} else {
|
||||
console.log(`No geocoding results for: ${address}`);
|
||||
res.json({
|
||||
success: false,
|
||||
message: 'No results found for this address'
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Geocoding error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Geocoding failed',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new RepresentativesController();
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,338 +0,0 @@
|
||||
const nocodbService = require('../services/nocodb');
|
||||
const { sendLoginDetails } = require('../services/email');
|
||||
const { sanitizeUser, extractId } = require('../utils/helpers');
|
||||
|
||||
class UsersController {
|
||||
async getAll(req, res) {
|
||||
try {
|
||||
console.log('UsersController.getAll called');
|
||||
console.log('Users table ID:', nocodbService.tableIds.users);
|
||||
|
||||
if (!nocodbService.tableIds.users) {
|
||||
console.error('Users table not configured in environment');
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Users table not configured. Please set NOCODB_TABLE_USERS in your environment variables.'
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Fetching users from NocoDB...');
|
||||
const response = await nocodbService.getAll(nocodbService.tableIds.users, {
|
||||
limit: 100
|
||||
});
|
||||
|
||||
const users = response.list || [];
|
||||
console.log(`Retrieved ${users.length} users from database`);
|
||||
|
||||
// Remove password field from response for security
|
||||
const safeUsers = users.map(sanitizeUser);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
users: safeUsers
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching users:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to fetch users: ' + error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async create(req, res) {
|
||||
try {
|
||||
const { email, password, name, phone, isAdmin, userType, expireDays } = req.body;
|
||||
|
||||
if (!email || !password) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Email and password are required'
|
||||
});
|
||||
}
|
||||
|
||||
if (!nocodbService.tableIds.users) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Users table not configured'
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user already exists
|
||||
console.log(`Checking if user exists with email: ${email}`);
|
||||
let existingUser = null;
|
||||
try {
|
||||
existingUser = await nocodbService.getUserByEmail(email);
|
||||
console.log('Existing user check result:', existingUser ? 'User exists' : 'User does not exist');
|
||||
} catch (error) {
|
||||
console.error('Error checking for existing user:', error.message);
|
||||
// Continue with creation if check fails - NocoDB will handle the unique constraint
|
||||
}
|
||||
|
||||
if (existingUser) {
|
||||
console.log('Existing user found:', { id: existingUser.ID || existingUser.Id || existingUser.id, email: existingUser.Email || existingUser.email });
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'User with this email already exists'
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate expiration date for temp users
|
||||
let expiresAt = null;
|
||||
if (userType === 'temp' && expireDays) {
|
||||
const expirationDate = new Date();
|
||||
expirationDate.setDate(expirationDate.getDate() + expireDays);
|
||||
expiresAt = expirationDate.toISOString();
|
||||
}
|
||||
|
||||
// Create new user - use the exact column titles from NocoDB schema
|
||||
const userData = {
|
||||
Email: email,
|
||||
Name: name || '',
|
||||
Password: password,
|
||||
Phone: phone || '',
|
||||
Admin: isAdmin === true,
|
||||
'User Type': userType || 'user',
|
||||
ExpiresAt: expiresAt,
|
||||
ExpireDays: userType === 'temp' ? expireDays : null,
|
||||
'Last Login': null
|
||||
};
|
||||
|
||||
const response = await nocodbService.create(
|
||||
nocodbService.tableIds.users,
|
||||
userData
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'User created successfully',
|
||||
user: {
|
||||
id: extractId(response),
|
||||
email: email,
|
||||
name: name,
|
||||
phone: phone,
|
||||
admin: isAdmin,
|
||||
userType: userType,
|
||||
expiresAt: expiresAt
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating user:', error);
|
||||
|
||||
// Check if it's a unique constraint violation (email already exists)
|
||||
if (error.response?.data?.code === '23505' ||
|
||||
error.response?.data?.message?.includes('already exists') ||
|
||||
error.message?.includes('already exists')) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'A user with this email address already exists'
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to create user: ' + (error.message || 'Unknown error')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async delete(req, res) {
|
||||
try {
|
||||
const userId = req.params.id;
|
||||
|
||||
if (!nocodbService.tableIds.users) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Users table not configured'
|
||||
});
|
||||
}
|
||||
|
||||
// Don't allow admins to delete themselves
|
||||
if (userId === req.session.userId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Cannot delete your own account'
|
||||
});
|
||||
}
|
||||
|
||||
await nocodbService.deleteUser(userId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'User deleted successfully'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error deleting user:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to delete user'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async sendLoginDetails(req, res) {
|
||||
try {
|
||||
const userId = req.params.id;
|
||||
|
||||
if (!nocodbService.tableIds.users) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Users table not configured'
|
||||
});
|
||||
}
|
||||
|
||||
// Get user data from database
|
||||
const user = await nocodbService.getById(
|
||||
nocodbService.tableIds.users,
|
||||
userId
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'User not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Send login details email
|
||||
await sendLoginDetails(user);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Login details sent successfully'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error sending login details:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to send login details'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async emailAllUsers(req, res) {
|
||||
try {
|
||||
const { subject, content } = req.body;
|
||||
|
||||
if (!subject || !content) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Subject and content are required'
|
||||
});
|
||||
}
|
||||
|
||||
if (!nocodbService.tableIds.users) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Users table not configured'
|
||||
});
|
||||
}
|
||||
|
||||
// Get all users
|
||||
const response = await nocodbService.getAll(nocodbService.tableIds.users, {
|
||||
limit: 1000
|
||||
});
|
||||
|
||||
const users = response.list || [];
|
||||
|
||||
if (users.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'No users found to email'
|
||||
});
|
||||
}
|
||||
|
||||
// Import email service
|
||||
const { sendEmail } = require('../services/email');
|
||||
const emailTemplates = require('../services/emailTemplates');
|
||||
|
||||
// Convert rich text content to plain text for the text version
|
||||
const stripHtmlTags = (html) => {
|
||||
return html.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
|
||||
};
|
||||
|
||||
// Prepare base template variables
|
||||
const baseTemplateVariables = {
|
||||
APP_NAME: 'BNKops Influence - User Broadcast',
|
||||
EMAIL_SUBJECT: subject,
|
||||
EMAIL_CONTENT: content,
|
||||
EMAIL_CONTENT_TEXT: stripHtmlTags(content),
|
||||
SENDER_NAME: req.session.userName || req.session.userEmail || 'Administrator',
|
||||
TIMESTAMP: new Date().toLocaleString()
|
||||
};
|
||||
|
||||
// Send emails to all users
|
||||
const emailResults = [];
|
||||
const failedEmails = [];
|
||||
|
||||
for (const user of users) {
|
||||
try {
|
||||
const userVariables = {
|
||||
...baseTemplateVariables,
|
||||
USER_NAME: user.Name || user.name || user.Email || user.email || 'User',
|
||||
USER_EMAIL: user.Email || user.email
|
||||
};
|
||||
|
||||
const emailContent = await emailTemplates.render('user-broadcast', userVariables);
|
||||
|
||||
await sendEmail({
|
||||
to: user.Email || user.email,
|
||||
subject: subject,
|
||||
text: emailContent.text,
|
||||
html: emailContent.html
|
||||
});
|
||||
|
||||
emailResults.push({
|
||||
email: user.Email || user.email,
|
||||
name: user.Name || user.name || user.Email || user.email,
|
||||
success: true
|
||||
});
|
||||
|
||||
console.log(`Sent broadcast email to: ${user.Email || user.email}`);
|
||||
} catch (emailError) {
|
||||
console.error(`Failed to send broadcast email to ${user.Email || user.email}:`, emailError);
|
||||
failedEmails.push({
|
||||
email: user.Email || user.email,
|
||||
name: user.Name || user.name || user.Email || user.email,
|
||||
error: emailError.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const successCount = emailResults.length;
|
||||
const failCount = failedEmails.length;
|
||||
|
||||
if (successCount === 0) {
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to send any emails',
|
||||
details: failedEmails
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Sent email to ${successCount} user${successCount !== 1 ? 's' : ''}${failCount > 0 ? `, ${failCount} failed` : ''}`,
|
||||
results: {
|
||||
successful: emailResults,
|
||||
failed: failedEmails,
|
||||
total: users.length,
|
||||
subject: subject
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error sending broadcast email:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to send broadcast email'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new UsersController();
|
||||
@ -1,196 +0,0 @@
|
||||
const nocodbService = require('../services/nocodb');
|
||||
|
||||
// Helper function to check if a temp user has expired
|
||||
const checkTempUserExpiration = async (req, res) => {
|
||||
if (req.session?.userType === 'temp' && req.session?.userEmail) {
|
||||
try {
|
||||
const user = await nocodbService.getUserByEmail(req.session.userEmail);
|
||||
if (user) {
|
||||
const expiration = user.ExpiresAt || user.expiresAt || user.Expiration || user.expiration;
|
||||
if (expiration) {
|
||||
const expirationDate = new Date(expiration);
|
||||
const now = new Date();
|
||||
|
||||
if (now > expirationDate) {
|
||||
console.warn(`Expired temp user session detected: ${req.session.userEmail}, expired: ${expiration}`);
|
||||
|
||||
// Destroy the session
|
||||
req.session.destroy((err) => {
|
||||
if (err) {
|
||||
console.error('Session destroy error:', err);
|
||||
}
|
||||
});
|
||||
|
||||
if (req.xhr || req.headers.accept?.indexOf('json') > -1) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: 'Account has expired. Please contact an administrator.',
|
||||
expired: true
|
||||
});
|
||||
} else {
|
||||
return res.redirect('/login.html?expired=true');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking temp user expiration:', error.message);
|
||||
// Don't fail the request on database errors, just log it
|
||||
}
|
||||
}
|
||||
return null; // No expiration issue
|
||||
};
|
||||
|
||||
const requireAuth = async (req, res, next) => {
|
||||
const isAuthenticated = (req.session && req.session.authenticated) ||
|
||||
(req.session && req.session.userId && req.session.userEmail);
|
||||
|
||||
if (isAuthenticated) {
|
||||
// Check if temp user has expired
|
||||
const expirationResponse = await checkTempUserExpiration(req, res);
|
||||
if (expirationResponse) {
|
||||
return; // Response already sent by checkTempUserExpiration
|
||||
}
|
||||
|
||||
// Set up req.user object for controllers that expect it
|
||||
req.user = {
|
||||
id: req.session.userId,
|
||||
email: req.session.userEmail,
|
||||
isAdmin: req.session.isAdmin || false,
|
||||
userType: req.session.userType || 'user',
|
||||
name: req.session.userName || req.session.user_name || null
|
||||
};
|
||||
|
||||
next();
|
||||
} else {
|
||||
console.warn('Unauthorized access attempt', {
|
||||
ip: req.ip,
|
||||
path: req.path,
|
||||
userAgent: req.get('User-Agent'),
|
||||
method: req.method,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
if (req.xhr || req.headers.accept?.indexOf('json') > -1) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: 'Authentication required'
|
||||
});
|
||||
} else {
|
||||
res.redirect('/login.html');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const requireAdmin = async (req, res, next) => {
|
||||
const isAuthenticated = (req.session && req.session.authenticated) ||
|
||||
(req.session && req.session.userId && req.session.userEmail);
|
||||
|
||||
if (isAuthenticated && req.session.isAdmin) {
|
||||
// Check if temp user has expired
|
||||
const expirationResponse = await checkTempUserExpiration(req, res);
|
||||
if (expirationResponse) {
|
||||
return; // Response already sent by checkTempUserExpiration
|
||||
}
|
||||
|
||||
// Set up req.user object for controllers that expect it
|
||||
req.user = {
|
||||
id: req.session.userId,
|
||||
email: req.session.userEmail,
|
||||
isAdmin: req.session.isAdmin || false,
|
||||
userType: req.session.userType || 'user',
|
||||
name: req.session.userName || req.session.user_name || null
|
||||
};
|
||||
|
||||
next();
|
||||
} else {
|
||||
console.warn('Unauthorized admin access attempt', {
|
||||
ip: req.ip,
|
||||
path: req.path,
|
||||
user: req.session?.userEmail || 'anonymous',
|
||||
userAgent: req.get('User-Agent')
|
||||
});
|
||||
|
||||
if (req.xhr || req.headers.accept?.indexOf('json') > -1) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: 'Admin access required'
|
||||
});
|
||||
} else {
|
||||
res.redirect('/login.html');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const requireNonTemp = async (req, res, next) => {
|
||||
const isAuthenticated = (req.session && req.session.authenticated) ||
|
||||
(req.session && req.session.userId && req.session.userEmail);
|
||||
|
||||
if (isAuthenticated && req.session.userType !== 'temp') {
|
||||
// Check if temp user has expired (shouldn't happen here, but for safety)
|
||||
const expirationResponse = await checkTempUserExpiration(req, res);
|
||||
if (expirationResponse) {
|
||||
return; // Response already sent by checkTempUserExpiration
|
||||
}
|
||||
|
||||
// Set up req.user object for controllers that expect it
|
||||
req.user = {
|
||||
id: req.session.userId,
|
||||
email: req.session.userEmail,
|
||||
isAdmin: req.session.isAdmin || false,
|
||||
userType: req.session.userType || 'user',
|
||||
name: req.session.userName || req.session.user_name || null
|
||||
};
|
||||
|
||||
next();
|
||||
} else {
|
||||
console.warn('Temp user access denied', {
|
||||
ip: req.ip,
|
||||
path: req.path,
|
||||
user: req.session?.userEmail || 'anonymous',
|
||||
userType: req.session?.userType || 'unknown'
|
||||
});
|
||||
|
||||
if (req.xhr || req.headers.accept?.indexOf('json') > -1) {
|
||||
res.status(403).json({
|
||||
success: false,
|
||||
error: 'Access denied for temporary users'
|
||||
});
|
||||
} else {
|
||||
res.redirect('/');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Optional authentication - sets req.user if authenticated, but doesn't block if not
|
||||
const optionalAuth = async (req, res, next) => {
|
||||
const isAuthenticated = (req.session && req.session.authenticated) ||
|
||||
(req.session && req.session.userId && req.session.userEmail);
|
||||
|
||||
if (isAuthenticated) {
|
||||
// Check if temp user has expired
|
||||
const expirationResponse = await checkTempUserExpiration(req, res);
|
||||
if (expirationResponse) {
|
||||
return; // Response already sent by checkTempUserExpiration
|
||||
}
|
||||
|
||||
// Set up req.user object for controllers that expect it
|
||||
req.user = {
|
||||
id: req.session.userId,
|
||||
email: req.session.userEmail,
|
||||
isAdmin: req.session.isAdmin || false,
|
||||
userType: req.session.userType || 'user',
|
||||
name: req.session.userName || req.session.user_name || null
|
||||
};
|
||||
}
|
||||
|
||||
// Continue regardless of authentication status
|
||||
next();
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
requireAuth,
|
||||
requireAdmin,
|
||||
requireNonTemp,
|
||||
optionalAuth
|
||||
};
|
||||
@ -1,142 +0,0 @@
|
||||
const csrf = require('csurf');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// Create CSRF protection middleware
|
||||
const csrfProtection = csrf({
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production' && process.env.HTTPS === 'true',
|
||||
sameSite: 'strict',
|
||||
maxAge: 3600000 // 1 hour
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Middleware to handle CSRF token errors
|
||||
*/
|
||||
const csrfErrorHandler = (err, req, res, next) => {
|
||||
if (err.code === 'EBADCSRFTOKEN') {
|
||||
logger.warn('CSRF token validation failed', {
|
||||
ip: req.ip,
|
||||
path: req.path,
|
||||
method: req.method,
|
||||
userAgent: req.get('user-agent')
|
||||
});
|
||||
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: 'Invalid CSRF token',
|
||||
message: 'Your session has expired or the request is invalid. Please refresh the page and try again.'
|
||||
});
|
||||
}
|
||||
|
||||
next(err);
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware to inject CSRF token into response
|
||||
* Adds csrfToken to all JSON responses and as a header
|
||||
*/
|
||||
const injectCsrfToken = (req, res, next) => {
|
||||
// Add token to response locals for template rendering
|
||||
res.locals.csrfToken = req.csrfToken();
|
||||
|
||||
// Override json method to automatically include CSRF token
|
||||
const originalJson = res.json.bind(res);
|
||||
res.json = function(data) {
|
||||
if (data && typeof data === 'object' && !data.csrfToken) {
|
||||
data.csrfToken = res.locals.csrfToken;
|
||||
}
|
||||
return originalJson(data);
|
||||
};
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
/**
|
||||
* Skip CSRF protection for specific routes (e.g., webhooks, public APIs)
|
||||
*/
|
||||
const csrfExemptRoutes = [
|
||||
'/api/health',
|
||||
'/api/metrics',
|
||||
'/api/config',
|
||||
'/api/auth/login', // Login uses credentials for authentication
|
||||
'/api/auth/logout', // Logout is an authentication action
|
||||
'/api/auth/session', // Session check is read-only
|
||||
'/api/representatives/postal/', // Read-only operation
|
||||
'/api/campaigns/public' // Public read operations
|
||||
];
|
||||
|
||||
const conditionalCsrfProtection = (req, res, next) => {
|
||||
// Skip CSRF for exempt routes
|
||||
const isExempt = csrfExemptRoutes.some(route => req.path.startsWith(route));
|
||||
|
||||
// Skip CSRF for GET, HEAD, OPTIONS (safe methods)
|
||||
const isSafeMethod = ['GET', 'HEAD', 'OPTIONS'].includes(req.method);
|
||||
|
||||
if (isExempt || isSafeMethod) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Log CSRF validation attempt for debugging
|
||||
console.log('=== CSRF VALIDATION ===');
|
||||
console.log('Method:', req.method);
|
||||
console.log('Path:', req.path);
|
||||
console.log('Body Token:', req.body?._csrf ? 'YES' : 'NO');
|
||||
console.log('Header Token:', req.headers['x-csrf-token'] ? 'YES' : 'NO');
|
||||
console.log('CSRF Cookie:', req.cookies['_csrf'] ? 'YES' : 'NO');
|
||||
console.log('Session ID:', req.session?.id || 'NO_SESSION');
|
||||
console.log('=======================');
|
||||
|
||||
// Apply CSRF protection for state-changing operations
|
||||
csrfProtection(req, res, (err) => {
|
||||
if (err) {
|
||||
console.log('=== CSRF ERROR ===');
|
||||
console.log('Error Message:', err.message);
|
||||
console.log('Error Code:', err.code);
|
||||
console.log('Path:', req.path);
|
||||
console.log('==================');
|
||||
logger.warn('CSRF token validation failed');
|
||||
csrfErrorHandler(err, req, res, next);
|
||||
} else {
|
||||
logger.info('CSRF validation passed for:', req.path);
|
||||
next();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper to get CSRF token for client-side use
|
||||
*/
|
||||
const getCsrfToken = (req, res) => {
|
||||
try {
|
||||
// Generate a CSRF token if one doesn't exist
|
||||
const token = req.csrfToken();
|
||||
console.log('=== CSRF TOKEN GENERATION ===');
|
||||
console.log('Token Length:', token?.length || 0);
|
||||
console.log('Has Token:', !!token);
|
||||
console.log('Session ID:', req.session?.id || 'NO_SESSION');
|
||||
console.log('Cookie will be set:', !!req.cookies);
|
||||
console.log('=============================');
|
||||
res.json({
|
||||
csrfToken: token
|
||||
});
|
||||
} catch (error) {
|
||||
console.log('=== CSRF TOKEN ERROR ===');
|
||||
console.log('Error:', error.message);
|
||||
console.log('Stack:', error.stack);
|
||||
console.log('========================');
|
||||
logger.error('Failed to generate CSRF token', { error: error.message, stack: error.stack });
|
||||
res.status(500).json({
|
||||
error: 'Failed to generate CSRF token'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
csrfProtection,
|
||||
csrfErrorHandler,
|
||||
injectCsrfToken,
|
||||
conditionalCsrfProtection,
|
||||
getCsrfToken
|
||||
};
|
||||
@ -1,47 +0,0 @@
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// Ensure uploads directory exists
|
||||
const uploadDir = path.join(__dirname, '../public/uploads/responses');
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Configure storage
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
// Generate unique filename: timestamp-random-originalname
|
||||
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||
const ext = path.extname(file.originalname);
|
||||
const basename = path.basename(file.originalname, ext);
|
||||
cb(null, `response-${uniqueSuffix}${ext}`);
|
||||
}
|
||||
});
|
||||
|
||||
// File filter - only images
|
||||
const fileFilter = (req, file, cb) => {
|
||||
const allowedTypes = /jpeg|jpg|png|gif|webp/;
|
||||
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
|
||||
const mimetype = allowedTypes.test(file.mimetype);
|
||||
|
||||
if (mimetype && extname) {
|
||||
return cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only image files (JPEG, PNG, GIF, WebP) are allowed'));
|
||||
}
|
||||
};
|
||||
|
||||
// Configure multer
|
||||
const upload = multer({
|
||||
storage: storage,
|
||||
limits: {
|
||||
fileSize: 5 * 1024 * 1024 // 5MB max file size
|
||||
},
|
||||
fileFilter: fileFilter
|
||||
});
|
||||
|
||||
module.exports = upload;
|
||||
@ -1,50 +0,0 @@
|
||||
{
|
||||
"name": "alberta-influence-campaign",
|
||||
"version": "1.0.0",
|
||||
"description": "A locally-hosted political influence campaign tool for Alberta constituents",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "nodemon server.js",
|
||||
"test": "jest"
|
||||
},
|
||||
"keywords": [
|
||||
"politics",
|
||||
"alberta",
|
||||
"campaign",
|
||||
"represent",
|
||||
"email"
|
||||
],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"cors": "^2.8.5",
|
||||
"helmet": "^7.0.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"express-validator": "^7.0.1",
|
||||
"express-rate-limit": "^6.8.1",
|
||||
"axios": "^1.5.0",
|
||||
"nodemailer": "^6.9.4",
|
||||
"express-session": "^1.17.3",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"qrcode": "^1.5.3",
|
||||
"winston": "^3.11.0",
|
||||
"winston-daily-rotate-file": "^4.7.1",
|
||||
"compression": "^1.7.4",
|
||||
"csurf": "^1.11.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"bull": "^4.12.0",
|
||||
"prom-client": "^15.1.0",
|
||||
"sharp": "^0.33.0",
|
||||
"ioredis": "^5.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1",
|
||||
"jest": "^29.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,813 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title id="page-title">Campaign - BNKops Influence Tool</title>
|
||||
<link rel="stylesheet" href="/css/styles.css">
|
||||
<style>
|
||||
.campaign-header {
|
||||
background: linear-gradient(135deg, #3498db, #2c3e50);
|
||||
color: white;
|
||||
padding: 3rem 0;
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.campaign-header.has-cover {
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
min-height: 350px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.campaign-header.has-cover::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.campaign-header > * {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.campaign-header h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.campaign-header-content {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.campaign-stats-header {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
justify-content: center;
|
||||
margin-top: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat-circle {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 1.8rem;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
line-height: 1;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.7rem;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-top: 0.25rem;
|
||||
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.share-buttons-header {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
margin-top: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.share-btn-small {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.share-btn-small:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.share-btn-small svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
fill: white;
|
||||
}
|
||||
|
||||
.share-btn-small.copied {
|
||||
background: rgba(40, 167, 69, 0.8);
|
||||
border-color: rgba(40, 167, 69, 1);
|
||||
}
|
||||
|
||||
.share-more-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.share-dropdown {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 0.5rem;
|
||||
background: white;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
padding: 0.5rem;
|
||||
z-index: 1000;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.share-dropdown.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.share-dropdown-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.share-dropdown-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0.75rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
background: rgba(52, 152, 219, 0.1);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-decoration: none;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.share-dropdown-item:hover {
|
||||
background: rgba(52, 152, 219, 0.2);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.share-dropdown-item svg {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-bottom: 0.25rem;
|
||||
fill: #2c3e50;
|
||||
}
|
||||
|
||||
.share-dropdown-item span {
|
||||
font-size: 0.7rem;
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.share-btn-small.more-btn {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.share-btn-small.more-btn.active {
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.campaign-content {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.call-to-action {
|
||||
background: #fff3cd;
|
||||
border: 1px solid #ffeaa7;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.user-info-form {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.email-preview {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.email-preview h3 {
|
||||
color: #495057;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.email-subject {
|
||||
font-weight: bold;
|
||||
color: #2c3e50;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.email-body {
|
||||
line-height: 1.6;
|
||||
color: #495057;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.email-edit-subject, .email-edit-body {
|
||||
width: 100%;
|
||||
border: 1px solid #ced4da;
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem;
|
||||
font-family: inherit;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.email-edit-subject {
|
||||
font-weight: bold;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.email-edit-body {
|
||||
min-height: 150px;
|
||||
resize: vertical;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.email-edit-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.preview-mode .email-edit-subject,
|
||||
.preview-mode .email-edit-body,
|
||||
.preview-mode .email-edit-actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.preview-mode .email-preview-actions {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.edit-mode .email-subject,
|
||||
.edit-mode .email-body,
|
||||
.edit-mode .email-preview-actions {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.representatives-grid {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.rep-card {
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
background: white;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.rep-card.custom-recipient {
|
||||
border-left: 4px solid #9b59b6;
|
||||
background: linear-gradient(135deg, #ffffff 0%, #f8f5fb 100%);
|
||||
}
|
||||
|
||||
.rep-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.rep-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.rep-photo {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
background: #e9ecef;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.custom-badge {
|
||||
display: inline-block;
|
||||
margin-left: 0.5rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: linear-gradient(135deg, #9b59b6, #8e44ad);
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: normal;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.rep-details h4 {
|
||||
margin: 0 0 0.25rem 0;
|
||||
color: #2c3e50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.rep-details p {
|
||||
margin: 0;
|
||||
color: #6c757d;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.rep-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.email-method-toggle {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.method-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.progress-steps {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 2rem;
|
||||
padding: 1rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.step {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.step.active {
|
||||
color: #3498db;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.step.completed {
|
||||
color: #27ae60;
|
||||
}
|
||||
|
||||
.step:not(:last-child)::after {
|
||||
content: '→';
|
||||
position: absolute;
|
||||
right: -50%;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0,0,0,0.7);
|
||||
display: none;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.loading-content {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Response Wall Button Styles */
|
||||
.response-wall-button {
|
||||
display: inline-block;
|
||||
padding: 1rem 2rem;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 50px;
|
||||
font-size: 1.1rem;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
transition: transform 0.3s, box-shadow 0.3s;
|
||||
animation: pulse-glow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.response-wall-button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.6);
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.response-wall-button::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: linear-gradient(
|
||||
45deg,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.1),
|
||||
transparent
|
||||
);
|
||||
transform: rotate(45deg);
|
||||
animation: shine 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse-glow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 4px 25px rgba(102, 126, 234, 0.8), 0 0 30px rgba(102, 126, 234, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shine {
|
||||
0% {
|
||||
left: -50%;
|
||||
}
|
||||
100% {
|
||||
left: 150%;
|
||||
}
|
||||
}
|
||||
|
||||
.response-wall-container {
|
||||
text-align: center;
|
||||
margin: 2rem 0;
|
||||
padding: 2rem;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.response-wall-container h3 {
|
||||
margin: 0 0 1rem 0;
|
||||
color: #2c3e50;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.response-wall-container p {
|
||||
margin: 0 0 1.5rem 0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.campaign-header h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.progress-steps {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.step:not(:last-child)::after {
|
||||
content: '↓';
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Loading Overlay -->
|
||||
<div id="loading-overlay" class="loading-overlay">
|
||||
<div class="loading-content">
|
||||
<div class="spinner"></div>
|
||||
<p id="loading-message">Loading campaign...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Campaign Header -->
|
||||
<div class="campaign-header">
|
||||
<div class="container">
|
||||
<div class="campaign-header-content">
|
||||
<h1 id="campaign-title">Loading Campaign...</h1>
|
||||
<p id="campaign-description"></p>
|
||||
|
||||
<!-- Campaign Stats in Header -->
|
||||
<div id="campaign-stats-header" class="campaign-stats-header" style="display: none;">
|
||||
<div id="email-stat-circle" class="stat-circle" style="display: none;">
|
||||
<div class="stat-number" id="email-count-header">0</div>
|
||||
<div class="stat-label">Emails</div>
|
||||
</div>
|
||||
<div id="call-stat-circle" class="stat-circle" style="display: none;">
|
||||
<div class="stat-number" id="call-count-header">0</div>
|
||||
<div class="stat-label">Calls</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Social Share Buttons in Header -->
|
||||
<div class="share-buttons-header">
|
||||
<!-- Expandable Social Menu -->
|
||||
<div class="share-socials-container">
|
||||
<button class="share-btn-primary" id="share-socials-toggle" title="Share on Socials">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class="share-icon">
|
||||
<path d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.16c-.05.21-.08.43-.08.65 0 1.61 1.31 2.92 2.92 2.92 1.61 0 2.92-1.31 2.92-2.92s-1.31-2.92-2.92-2.92z"/>
|
||||
</svg>
|
||||
<span>Socials</span>
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" class="chevron-icon">
|
||||
<path d="M7 10l5 5 5-5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Expandable Social Options -->
|
||||
<div class="share-socials-menu" id="share-socials-menu">
|
||||
<button class="share-btn-small" id="share-facebook" title="Facebook">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-twitter" title="Twitter/X">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-linkedin" title="LinkedIn">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-whatsapp" title="WhatsApp">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-bluesky" title="Bluesky">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.206-.659-.298-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8Z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-instagram" title="Instagram">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M7.8 2h8.4C19.4 2 22 4.6 22 7.8v8.4a5.8 5.8 0 0 1-5.8 5.8H7.8C4.6 22 2 19.4 2 16.2V7.8A5.8 5.8 0 0 1 7.8 2m-.2 2A3.6 3.6 0 0 0 4 7.6v8.8C4 18.39 5.61 20 7.6 20h8.8a3.6 3.6 0 0 0 3.6-3.6V7.6C20 5.61 18.39 4 16.4 4H7.6m9.65 1.5a1.25 1.25 0 0 1 1.25 1.25A1.25 1.25 0 0 1 17.25 8 1.25 1.25 0 0 1 16 6.75a1.25 1.25 0 0 1 1.25-1.25M12 7a5 5 0 0 1 5 5 5 5 0 0 1-5 5 5 5 0 0 1-5-5 5 5 0 0 1 5-5m0 2a3 3 0 0 0-3 3 3 3 0 0 0 3 3 3 3 0 0 0 3-3 3 3 0 0 0-3-3z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-reddit" title="Reddit">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0zm5.01 4.744c.688 0 1.25.561 1.25 1.249a1.25 1.25 0 0 1-2.498.056l-2.597-.547-.8 3.747c1.824.07 3.48.632 4.674 1.488.308-.309.73-.491 1.207-.491.968 0 1.754.786 1.754 1.754 0 .716-.435 1.333-1.01 1.614a3.111 3.111 0 0 1 .042.52c0 2.694-3.13 4.87-7.004 4.87-3.874 0-7.004-2.176-7.004-4.87 0-.183.015-.366.043-.534A1.748 1.748 0 0 1 4.028 12c0-.968.786-1.754 1.754-1.754.463 0 .898.196 1.207.49 1.207-.883 2.878-1.43 4.744-1.487l.885-4.182a.342.342 0 0 1 .14-.197.35.35 0 0 1 .238-.042l2.906.617a1.214 1.214 0 0 1 1.108-.701zM9.25 12C8.561 12 8 12.562 8 13.25c0 .687.561 1.248 1.25 1.248.687 0 1.248-.561 1.248-1.249 0-.688-.561-1.249-1.249-1.249zm5.5 0c-.687 0-1.248.561-1.248 1.25 0 .687.561 1.248 1.249 1.248.688 0 1.249-.561 1.249-1.249 0-.687-.562-1.249-1.25-1.249zm-5.466 3.99a.327.327 0 0 0-.231.094.33.33 0 0 0 0 .463c.842.842 2.484.913 2.961.913.477 0 2.105-.056 2.961-.913a.361.361 0 0 0 .029-.463.33.33 0 0 0-.464 0c-.547.533-1.684.73-2.512.73-.828 0-1.979-.196-2.512-.73a.326.326 0 0 0-.232-.095z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-threads" title="Threads">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.186 24h-.007c-3.581-.024-6.334-1.205-8.184-3.509C2.35 18.44 1.5 15.586 1.472 12.01v-.017c.03-3.579.879-6.43 2.525-8.482C5.845 1.205 8.6.024 12.18 0h.014c2.746.02 5.043.725 6.826 2.098 1.677 1.29 2.858 3.13 3.509 5.467l-2.04.569c-.542-1.947-1.499-3.488-2.846-4.576-1.488-1.2-3.457-1.806-5.854-1.826h-.01c-3.015.022-5.26.918-6.675 2.662C3.873 6.034 3.13 8.39 3.108 11.98v.014c.022 3.585.766 5.937 2.209 6.99 1.407 1.026 3.652 1.545 6.674 1.545h.01c.297 0 .597-.002.892-.009l.034 2.037c-.33.008-.665.012-1.001.012zM17.822 15.13v.002c-.184 1.376-.865 2.465-2.025 3.234-1.222.81-2.878 1.221-4.922 1.221-1.772 0-3.185-.34-4.197-1.009-.944-.625-1.488-1.527-1.617-2.68-.119-1.066.152-2.037.803-2.886.652-.85 1.595-1.464 2.802-1.823 1.102-.33 2.396-.495 3.847-.495h.343v1.615h-.343c-1.274 0-2.395.144-3.332.428-.937.284-1.653.713-2.129 1.275-.476.562-.664 1.229-.556 1.979.097.671.45 1.21 1.051 1.603.723.473 1.816.711 3.252.711 1.738 0 3.097-.35 4.042-.995.809-.552 1.348-1.349 1.603-2.373l1.98.193zM12.626 10.561v.002c-1.197 0-2.234.184-3.083.546-.938.4-1.668 1.017-2.169 1.835-.499.816-.748 1.792-.739 2.902l-2.037-.022c-.012-1.378.304-2.608.939-3.658.699-1.158 1.688-2.065 2.941-2.696 1.05-.527 2.274-.792 3.638-.792h.51v1.883h-.51z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-telegram" title="Telegram">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-mastodon" title="Mastodon">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M23.268 5.313c-.35-2.578-2.617-4.61-5.304-5.004C17.51.242 15.792 0 11.813 0h-.03c-3.98 0-4.835.242-5.288.309C3.882.692 1.496 2.518.917 5.127.64 6.412.61 7.837.661 9.143c.074 1.874.088 3.745.26 5.611.118 1.24.325 2.47.62 3.68.55 2.237 2.777 4.098 4.96 4.857 2.336.792 4.849.923 7.256.38.265-.061.527-.132.786-.213.585-.184 1.27-.39 1.774-.753a.057.057 0 0 0 .023-.043v-1.809a.052.052 0 0 0-.02-.041.053.053 0 0 0-.046-.01 20.282 20.282 0 0 1-4.709.545c-2.73 0-3.463-1.284-3.674-1.818a5.593 5.593 0 0 1-.319-1.433.053.053 0 0 1 .066-.054c1.517.363 3.072.546 4.632.546.376 0 .75 0 1.125-.01 1.57-.044 3.224-.124 4.768-.422.038-.008.077-.015.11-.024 2.435-.464 4.753-1.92 4.989-5.604.008-.145.03-1.52.03-1.67.002-.512.167-3.63-.024-5.545zm-3.748 9.195h-2.561V8.29c0-1.309-.55-1.976-1.67-1.976-1.23 0-1.846.79-1.846 2.35v3.403h-2.546V8.663c0-1.56-.617-2.35-1.848-2.35-1.112 0-1.668.668-1.67 1.977v6.218H4.822V8.102c0-1.31.337-2.35 1.011-3.12.696-.77 1.608-1.164 2.74-1.164 1.311 0 2.302.5 2.962 1.498l.638 1.06.638-1.06c.66-.999 1.65-1.498 2.96-1.498 1.13 0 2.043.395 2.74 1.164.675.77 1.012 1.81 1.012 3.12z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-sms" title="SMS">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12zM7 9h2v2H7zm4 0h2v2h-2zm4 0h2v2h-2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-slack" title="Slack">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zm10.122 2.521a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.268 0a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zm-2.523 10.122a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zm0-1.268a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-discord" title="Discord">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-print" title="Print">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 8H5c-1.66 0-3 1.34-3 3v6h4v4h12v-4h4v-6c0-1.66-1.34-3-3-3zm-3 11H8v-5h8v5zm3-7c-.55 0-1-.45-1-1s.45-1 1-1 1 .45 1 1-.45 1-1 1zm-1-9H6v4h12V3z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="share-btn-small" id="share-email" title="Email">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 4l-8 5-8-5V6l8 5 8-5v2z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Always-visible buttons -->
|
||||
<button class="share-btn-primary" id="share-copy" title="Copy Link">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"/>
|
||||
</svg>
|
||||
<span>Copy Link</span>
|
||||
</button>
|
||||
<button class="share-btn-primary" id="share-qrcode" title="Show QR Code">
|
||||
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 11h8V3H3v8zm2-6h4v4H5V5zM3 21h8v-8H3v8zm2-6h4v4H5v-4zM13 3v8h8V3h-8zm6 6h-4V5h4v4zM13 13h2v2h-2zM15 15h2v2h-2zM13 17h2v2h-2zM17 13h2v2h-2zM19 15h2v2h-2zM17 17h2v2h-2zM19 19h2v2h-2z"/>
|
||||
</svg>
|
||||
<span>QR Code</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="campaign-content">
|
||||
<!-- Call to Action -->
|
||||
<div id="call-to-action" class="call-to-action" style="display: none;">
|
||||
<!-- Content will be loaded dynamically -->
|
||||
</div>
|
||||
|
||||
<!-- Progress Steps -->
|
||||
<div class="progress-steps">
|
||||
<div class="step active" id="step-info">Enter Your Info</div>
|
||||
<div class="step" id="step-postal">Find Representatives</div>
|
||||
<div class="step" id="step-send">Send Messages</div>
|
||||
</div>
|
||||
|
||||
<!-- User Information Form -->
|
||||
<div id="user-info-section" class="user-info-form">
|
||||
<h2>Your Information</h2>
|
||||
<p>We need some basic information to find your representatives and track campaign engagement.</p>
|
||||
|
||||
<form id="user-info-form">
|
||||
<div class="form-group">
|
||||
<label for="user-postal-code">Your Postal Code *</label>
|
||||
<input type="text" id="user-postal-code" name="postalCode" required
|
||||
placeholder="T5K 2M5" maxlength="7" style="text-transform: uppercase;">
|
||||
</div>
|
||||
|
||||
<div id="optional-fields" style="display: none;">
|
||||
<div class="form-group">
|
||||
<label for="user-name">Your Name</label>
|
||||
<input type="text" id="user-name" name="userName" placeholder="Your full name">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="user-email">Your Email (Optional - If you would like a reply)</label>
|
||||
<input type="email" id="user-email" name="userEmail" placeholder="your@email.com">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Find My Representatives</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Email Preview -->
|
||||
<div id="email-preview" class="email-preview preview-mode" style="display: none;">
|
||||
<h3>📧 Email Preview</h3>
|
||||
<p id="preview-description">This is the message that will be sent to your representatives:</p>
|
||||
|
||||
<!-- Read-only preview -->
|
||||
<div class="email-subject" id="preview-subject"></div>
|
||||
<div class="email-body" id="preview-body"></div>
|
||||
|
||||
<!-- Editable fields -->
|
||||
<input type="text" class="email-edit-subject" id="edit-subject" placeholder="Email Subject">
|
||||
<textarea class="email-edit-body" id="edit-body" placeholder="Email Body"></textarea>
|
||||
|
||||
<div class="email-edit-actions">
|
||||
<button type="button" class="btn btn-secondary" id="preview-email-btn">👁️ Preview</button>
|
||||
<button type="button" class="btn btn-primary" id="save-email-btn">💾 Save Changes</button>
|
||||
</div>
|
||||
|
||||
<!-- Preview mode actions -->
|
||||
<div class="email-preview-actions" style="display: none; margin-top: 1rem; text-align: center;">
|
||||
<button type="button" class="btn btn-secondary" id="edit-email-btn">✏️ Edit Email</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Representatives Section -->
|
||||
<div id="representatives-section" style="display: none;">
|
||||
<h2>Your Representatives</h2>
|
||||
<p>Select how you'd like to contact each representative:</p>
|
||||
|
||||
<!-- Email Method Selection -->
|
||||
<div id="email-method-selection" class="email-method-toggle">
|
||||
<div class="method-option">
|
||||
<input type="radio" id="method-smtp" name="emailMethod" value="smtp" checked>
|
||||
<label for="method-smtp">📧 Send via our system</label>
|
||||
</div>
|
||||
<div class="method-option">
|
||||
<input type="radio" id="method-mailto" name="emailMethod" value="mailto">
|
||||
<label for="method-mailto">📬 Open in your email app</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="representatives-list" class="representatives-grid">
|
||||
<!-- Representatives will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Response Wall Button -->
|
||||
<div id="response-wall-section" class="response-wall-container" style="display: none;">
|
||||
<h3>💬 See What People Are Saying</h3>
|
||||
<p>Check out responses to people who have taken action on this campaign</p>
|
||||
<a href="#" id="response-wall-link" class="response-wall-button">
|
||||
View Response Wall
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Success Message -->
|
||||
<div id="success-section" style="display: none; text-align: center; padding: 2rem;">
|
||||
<h2 style="color: #27ae60;">🎉 Thank you for taking action!</h2>
|
||||
<p>Your emails have been processed. Democracy works when people like you get involved.</p>
|
||||
<button class="btn btn-secondary" data-action="reload-page">Send More Emails</button>
|
||||
</div>
|
||||
|
||||
<!-- Error Messages -->
|
||||
<div id="error-message" class="error-message" style="display: none;"></div>
|
||||
</div>
|
||||
|
||||
<!-- QR Code Modal -->
|
||||
<div id="qrcode-modal" class="qrcode-modal">
|
||||
<div class="qrcode-modal-content">
|
||||
<span class="qrcode-close">×</span>
|
||||
<h2>Scan QR Code to Visit Campaign</h2>
|
||||
<div class="qrcode-container">
|
||||
<img id="qrcode-image" src="" alt="Campaign QR Code">
|
||||
</div>
|
||||
<p class="qrcode-instructions">Scan this code with your phone to visit this campaign page</p>
|
||||
<button class="btn btn-secondary" id="download-qrcode-btn">Download QR Code</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer style="text-align: center; margin-top: 2rem; padding: 1rem; border-top: 1px solid #e9ecef;">
|
||||
<p>© 2025 <a href="https://bnkops.com/" target="_blank">BNKops</a> Influence Tool. Connect with democracy.</p>
|
||||
<p><small><a href="/terms.html" id="terms-link" target="_blank">Terms of Use & Privacy Notice</a></small></p>
|
||||
<div style="margin-top: 1rem;">
|
||||
<a href="/index.html" id="home-link" class="btn btn-secondary">Return to Main Page</a>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="/js/api-client.js"></script>
|
||||
<script src="/js/campaign.js"></script>
|
||||
<script>
|
||||
// Update footer links with APP_URL if needed for cross-origin scenarios
|
||||
fetch('/api/config')
|
||||
.then(res => res.json())
|
||||
.then(config => {
|
||||
if (config.appUrl && !window.location.href.startsWith(config.appUrl)) {
|
||||
// Only update if we're on a different domain (e.g., CDN)
|
||||
document.getElementById('terms-link').href = config.appUrl + '/terms.html';
|
||||
document.getElementById('home-link').href = config.appUrl + '/index.html';
|
||||
}
|
||||
})
|
||||
.catch(err => console.log('Config not loaded, using relative paths'));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,884 +0,0 @@
|
||||
/* Response Wall Styles */
|
||||
|
||||
/* Campaign Header Styles */
|
||||
.response-wall-header {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 3rem 0;
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.response-wall-header.has-cover {
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
min-height: 350px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.response-wall-header.has-cover::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.response-wall-header > * {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.response-wall-header h1 {
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.response-wall-header .campaign-subtitle {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 500;
|
||||
margin: 0.5rem 0 1rem 0;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.7);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.response-wall-header p {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.7);
|
||||
}
|
||||
|
||||
.response-wall-header-content {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.header-nav-buttons {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
margin-top: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Social Share Buttons in Header */
|
||||
.share-buttons-header {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: center;
|
||||
margin-top: 1rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
/* Primary Share Buttons */
|
||||
.share-btn-primary {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
color: white;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
backdrop-filter: blur(10px);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.share-btn-primary:hover {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.share-btn-primary svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
fill: white;
|
||||
}
|
||||
|
||||
.share-btn-primary.copied {
|
||||
background: rgba(40, 167, 69, 0.9);
|
||||
border-color: rgba(40, 167, 69, 1);
|
||||
}
|
||||
|
||||
/* Expandable Social Menu Container */
|
||||
.share-socials-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.share-socials-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||
padding: 0.75rem;
|
||||
display: none;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
z-index: 1000;
|
||||
min-width: 280px;
|
||||
max-width: 320px;
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(-10px);
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
}
|
||||
|
||||
.share-socials-menu.show {
|
||||
display: flex;
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
/* Chevron icon rotation */
|
||||
.share-btn-primary .chevron-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: white;
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.share-btn-primary.active .chevron-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Share icon */
|
||||
.share-btn-primary .share-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
fill: white;
|
||||
}
|
||||
|
||||
/* Social buttons inside menu */
|
||||
.share-socials-menu button {
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
padding: 0;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.share-socials-menu button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.share-socials-menu button svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
fill: white;
|
||||
}
|
||||
|
||||
/* Platform-specific colors */
|
||||
#share-facebook {
|
||||
background: #1877f2;
|
||||
}
|
||||
|
||||
#share-twitter {
|
||||
background: #000000;
|
||||
}
|
||||
|
||||
#share-linkedin {
|
||||
background: #0077b5;
|
||||
}
|
||||
|
||||
#share-whatsapp {
|
||||
background: #25d366;
|
||||
}
|
||||
|
||||
#share-bluesky {
|
||||
background: #1185fe;
|
||||
}
|
||||
|
||||
#share-instagram {
|
||||
background: linear-gradient(45deg, #f09433 0%, #e6683c 25%, #dc2743 50%, #cc2366 75%, #bc1888 100%);
|
||||
}
|
||||
|
||||
#share-reddit {
|
||||
background: #ff4500;
|
||||
}
|
||||
|
||||
#share-threads {
|
||||
background: #000000;
|
||||
}
|
||||
|
||||
#share-telegram {
|
||||
background: #0088cc;
|
||||
}
|
||||
|
||||
#share-mastodon {
|
||||
background: #6364ff;
|
||||
}
|
||||
|
||||
#share-sms {
|
||||
background: #34c759;
|
||||
}
|
||||
|
||||
#share-slack {
|
||||
background: #4a154b;
|
||||
}
|
||||
|
||||
#share-discord {
|
||||
background: #5865f2;
|
||||
}
|
||||
|
||||
#share-print {
|
||||
background: #6c757d;
|
||||
}
|
||||
|
||||
#share-email {
|
||||
background: #ea4335;
|
||||
}
|
||||
|
||||
.share-btn-small {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
border-radius: 50%;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.share-btn-small:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.share-btn-small svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
fill: white;
|
||||
}
|
||||
|
||||
.share-btn-small.copied {
|
||||
background: rgba(40, 167, 69, 0.8);
|
||||
border-color: rgba(40, 167, 69, 1);
|
||||
}
|
||||
|
||||
.stats-banner {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 2rem;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
display: block;
|
||||
font-size: 3rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #ffffff;
|
||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
display: block;
|
||||
font-size: 1rem;
|
||||
color: #ffffff;
|
||||
opacity: 1;
|
||||
font-weight: 500;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.response-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 2rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.filter-group select {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
#submit-response-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Response Card */
|
||||
.response-card {
|
||||
background: white;
|
||||
border: 1px solid #e1e8ed;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.response-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.response-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.response-rep-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.response-rep-info h3 {
|
||||
margin: 0 0 0.25rem 0;
|
||||
color: #1a202c;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.response-rep-info .rep-meta {
|
||||
color: #7f8c8d;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.response-rep-info .rep-meta span {
|
||||
margin-right: 1rem;
|
||||
}
|
||||
|
||||
.response-badges {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-verified {
|
||||
background: #27ae60;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-level {
|
||||
background: #3498db;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-type {
|
||||
background: #95a5a6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.response-content {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.response-text {
|
||||
background: #f8f9fa;
|
||||
padding: 1rem;
|
||||
border-left: 4px solid #3498db;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.user-comment {
|
||||
padding: 0.75rem;
|
||||
background: #fff3cd;
|
||||
border-left: 4px solid #ffc107;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.user-comment-label {
|
||||
font-weight: 600;
|
||||
color: #856404;
|
||||
margin-bottom: 0.5rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.response-screenshot {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.response-screenshot img {
|
||||
max-width: 100%;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #ddd;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.response-screenshot img:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.response-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #e1e8ed;
|
||||
}
|
||||
|
||||
.response-meta {
|
||||
color: #7f8c8d;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.response-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.upvote-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 2px solid #3498db;
|
||||
background: white;
|
||||
color: #3498db;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.upvote-btn:hover {
|
||||
background: #3498db;
|
||||
color: white;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.upvote-btn.upvoted {
|
||||
background: #3498db;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.upvote-btn .upvote-icon {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.upvote-count {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Verify Button */
|
||||
.verify-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 2px solid #27ae60;
|
||||
background: white;
|
||||
color: #27ae60;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.verify-btn:hover {
|
||||
background: #27ae60;
|
||||
color: white;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(39, 174, 96, 0.2);
|
||||
}
|
||||
|
||||
.verify-btn .verify-icon {
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.verify-btn .verify-text {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Empty State */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #7f8c8d;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
/* Modal Styles */
|
||||
.modal {
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background-color: white;
|
||||
margin: 5% auto;
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
max-width: 600px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.close {
|
||||
color: #aaa;
|
||||
float: right;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.close:hover,
|
||||
.close:focus {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
color: #1a202c;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
color: #7f8c8d;
|
||||
}
|
||||
|
||||
/* Postal Lookup Styles */
|
||||
.postal-lookup-container {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.postal-lookup-container input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.postal-lookup-container .btn {
|
||||
white-space: nowrap;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
#rep-select {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 2px solid #3498db;
|
||||
border-radius: 4px;
|
||||
font-size: 0.95rem;
|
||||
background: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#rep-select option {
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#rep-select option:hover {
|
||||
background: #f0f8ff;
|
||||
}
|
||||
|
||||
#rep-select-group {
|
||||
background: #f8f9fa;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #e1e8ed;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.form-actions .btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Checkbox styling */
|
||||
.form-group input[type="checkbox"] {
|
||||
width: auto;
|
||||
margin-right: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-group label:has(input[type="checkbox"]) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-weight: normal;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-group input[type="checkbox"]:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #7f8c8d;
|
||||
}
|
||||
|
||||
.load-more-container {
|
||||
text-align: center;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.stats-banner {
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.response-controls {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
#submit-response-btn {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.response-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.response-badges {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.response-footer {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.response-actions {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.verify-btn,
|
||||
.upvote-btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
margin: 10% 5%;
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* QR Code Modal Styles */
|
||||
.qrcode-modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.qrcode-modal.show {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.qrcode-modal-content {
|
||||
background-color: #fefefe;
|
||||
margin: auto;
|
||||
padding: 2rem;
|
||||
border-radius: 12px;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
position: relative;
|
||||
animation: slideDown 0.3s ease-in-out;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.qrcode-close {
|
||||
color: #aaa;
|
||||
position: absolute;
|
||||
right: 1.5rem;
|
||||
top: 1rem;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.qrcode-close:hover,
|
||||
.qrcode-close:focus {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.qrcode-modal-content h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #2c3e50;
|
||||
text-align: center;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.qrcode-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 1.5rem;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.qrcode-container img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.qrcode-instructions {
|
||||
text-align: center;
|
||||
color: #6c757d;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.qrcode-modal-content .btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
transform: translateY(-50px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,285 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Email Testing Interface - BNKops Influence Campaign</title>
|
||||
<link rel="stylesheet" href="css/styles.css">
|
||||
<style>
|
||||
.test-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.test-section {
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.test-controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.test-controls button {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: #007bff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: #6c757d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background-color: #28a745;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background-color: #ffc107;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.test-controls button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.test-controls button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.email-preview {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
padding: 20px;
|
||||
margin-top: 20px;
|
||||
background: white;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.email-preview.empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #6c757d;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.logs-section {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
background: white;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.log-entry.test-mode {
|
||||
border-left: 4px solid #ffc107;
|
||||
}
|
||||
|
||||
.log-entry.failed {
|
||||
border-left: 4px solid #dc3545;
|
||||
}
|
||||
|
||||
.log-entry.sent {
|
||||
border-left: 4px solid #28a745;
|
||||
}
|
||||
|
||||
.log-meta {
|
||||
font-size: 12px;
|
||||
color: #6c757d;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-sent {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.status-failed {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.status-test {
|
||||
background-color: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="test-container">
|
||||
<header style="text-align: center; margin-bottom: 30px;">
|
||||
<h1>Email Testing Interface</h1>
|
||||
<p>Test and preview emails before sending to elected officials</p>
|
||||
<div id="auth-status" style="margin-top: 10px;"></div>
|
||||
</header>
|
||||
|
||||
<!-- Quick Test Section -->
|
||||
<div class="test-section">
|
||||
<h2>Quick Test</h2>
|
||||
<p>Send a test email to yourself to verify email configuration</p>
|
||||
<div class="test-controls">
|
||||
<button id="quick-test-btn" class="btn-primary">Send Quick Test Email</button>
|
||||
<button id="smtp-test-btn" class="btn-secondary">Test SMTP Connection</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Email Composition & Preview Section -->
|
||||
<div class="test-section">
|
||||
<h2>Email Preview & Test</h2>
|
||||
<form id="email-test-form">
|
||||
<div class="form-group">
|
||||
<label for="test-recipient">Test Recipient Email:</label>
|
||||
<input type="email" id="test-recipient" name="recipientEmail"
|
||||
placeholder="Enter email address for testing">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="test-subject">Subject:</label>
|
||||
<input type="text" id="test-subject" name="subject"
|
||||
placeholder="Enter email subject" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="test-message">Message:</label>
|
||||
<textarea id="test-message" name="message"
|
||||
placeholder="Enter your message to elected officials..." required></textarea>
|
||||
</div>
|
||||
|
||||
<div class="test-controls">
|
||||
<button type="button" id="preview-btn" class="btn-secondary">Preview Email</button>
|
||||
<button type="button" id="send-test-btn" class="btn-primary">Send Test Email</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="email-preview" class="email-preview empty">
|
||||
Click "Preview Email" to see how your email will look
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Email Logs Section -->
|
||||
<div class="test-section">
|
||||
<h2>Email Logs</h2>
|
||||
<div class="test-controls">
|
||||
<button id="refresh-logs-btn" class="btn-secondary">Refresh Logs</button>
|
||||
<button id="filter-test-btn" class="btn-warning">Show Test Emails Only</button>
|
||||
<button id="filter-all-btn" class="btn-success">Show All Emails</button>
|
||||
</div>
|
||||
<div id="email-logs" class="logs-section">
|
||||
<div class="loading">Loading email logs...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Test Mode Status -->
|
||||
<div class="test-section">
|
||||
<h2>Current Configuration</h2>
|
||||
<div id="config-status">
|
||||
<div class="loading">Loading configuration...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success/Error Messages -->
|
||||
<div id="message-container"></div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="js/auth.js"></script>
|
||||
<script src="js/api-client.js"></script>
|
||||
<script src="js/email-testing.js"></script>
|
||||
|
||||
<script>
|
||||
// Initialize the email testing interface
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Check authentication
|
||||
const authManager = new AuthManager();
|
||||
authManager.checkAuthStatus().then(isAuthenticated => {
|
||||
if (!isAuthenticated) {
|
||||
window.location.href = '/login.html?redirect=/email-test.html';
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize email testing
|
||||
const emailTest = new EmailTesting();
|
||||
emailTest.init();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,5 +0,0 @@
|
||||
<!-- Minimal favicon to prevent 404 errors -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<circle cx="16" cy="16" r="14" fill="#007bff"/>
|
||||
<text x="16" y="20" text-anchor="middle" fill="white" font-family="Arial" font-size="16" font-weight="bold">I</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 283 B |
@ -1,277 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>BNKops Influence Campaign Tool</title>
|
||||
<link rel="icon" href="data:,">
|
||||
|
||||
<!-- Leaflet CSS -->
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin="" />
|
||||
|
||||
<link rel="stylesheet" href="css/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<main>
|
||||
<!-- Postal Code Input Section -->
|
||||
<section id="postal-input-section" class="unified-header-section">
|
||||
<div class="section-background">
|
||||
<div class="gradient-overlay"></div>
|
||||
<div class="particles">
|
||||
<span class="particle">🇨🇦</span>
|
||||
<span class="particle">📧</span>
|
||||
<span class="particle">📞</span>
|
||||
<span class="particle">✉️</span>
|
||||
<span class="particle">📱</span>
|
||||
<span class="particle">🇨🇦</span>
|
||||
<span class="particle">📧</span>
|
||||
<span class="particle">📞</span>
|
||||
<span class="particle">🇨🇦</span>
|
||||
<span class="particle">📱</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Header Content -->
|
||||
<div class="header-content">
|
||||
<h1 class="fade-in"><a href="https://bnkops.com/" target="_blank" class="brand-link">BNKops</a> Influence Tool</h1>
|
||||
<p class="fade-in-delay">Connect with your elected representatives across all levels of government</p>
|
||||
</div>
|
||||
|
||||
<div class="map-header">
|
||||
<h2>Find Your Representatives</h2>
|
||||
</div>
|
||||
|
||||
<!-- Postal Code Input -->
|
||||
<div class="postal-input-section">
|
||||
<form id="postal-form">
|
||||
<div class="form-group">
|
||||
<label for="postal-code">Enter your postal code:</label>
|
||||
<div class="input-group">
|
||||
<input
|
||||
type="text"
|
||||
id="postal-code"
|
||||
name="postal-code"
|
||||
placeholder="T5K 2M5"
|
||||
maxlength="7"
|
||||
required
|
||||
pattern="^[Tt]\d[A-Za-z]\s?\d[A-Za-z]\d$"
|
||||
title="Please enter a valid Alberta postal code (starting with T)"
|
||||
>
|
||||
<button type="submit" class="btn btn-primary">Search</button>
|
||||
<button type="button" id="refresh-btn" class="btn btn-secondary" style="display: none;">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="error-message" class="error-message" style="display: none;"></div>
|
||||
<div id="loading" class="loading" style="display: none;">
|
||||
<div class="spinner"></div>
|
||||
<p>Looking up your representatives...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Highlighted Campaign Section (inside blue background) -->
|
||||
<div id="highlighted-campaign-section" class="highlighted-campaign-section" style="display: none;">
|
||||
<div id="highlighted-campaign-container">
|
||||
<!-- Highlighted campaign will be dynamically inserted here -->
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Representatives Display Section -->
|
||||
<section id="representatives-section" style="display: none;">
|
||||
<div id="location-info" class="location-info">
|
||||
<h3>Your Location</h3>
|
||||
<p id="location-details"></p>
|
||||
</div>
|
||||
|
||||
<div id="representatives-container">
|
||||
<!-- Representatives will be dynamically inserted here -->
|
||||
</div>
|
||||
|
||||
<!-- Map showing office locations -->
|
||||
<div class="map-header">
|
||||
<h3>Representative Office Locations</h3>
|
||||
</div>
|
||||
|
||||
<div id="map-container" class="map-container">
|
||||
<div id="main-map" style="height: 400px; width: 100%;"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- epose Modal -->
|
||||
<div id="email-modal" class="modal" style="display: none;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Compose Email</h3>
|
||||
<span class="close-btn" id="close-modal">×</span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="email-form">
|
||||
<input type="hidden" id="recipient-email" name="recipient-email">
|
||||
<input type="hidden" id="sender-postal-code" name="sender-postal-code">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="recipient-info">To:</label>
|
||||
<div id="recipient-info" class="recipient-info"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="sender-name">Your Name:</label>
|
||||
<input type="text" id="sender-name" name="sender-name" required maxlength="100">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="sender-email">Your Email:</label>
|
||||
<input type="email" id="sender-email" name="sender-email" required maxlength="200">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email-subject">Subject:</label>
|
||||
<input type="text" id="email-subject" name="email-subject" required maxlength="200">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email-message">Message:</label>
|
||||
<textarea
|
||||
id="email-message"
|
||||
name="email-message"
|
||||
rows="10"
|
||||
required
|
||||
maxlength="5000"
|
||||
placeholder="Write your message to your representative here..."
|
||||
></textarea>
|
||||
<small class="char-counter">5000 characters remaining</small>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="button" id="cancel-email" class="btn btn-secondary">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Preview Email</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Email Preview Modal -->
|
||||
<div id="email-preview-modal" class="modal" style="display: none;">
|
||||
<div class="modal-content preview-modal">
|
||||
<div class="modal-header">
|
||||
<h3>Email Preview</h3>
|
||||
<span class="close-btn" id="close-preview-modal">×</span>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="preview-section">
|
||||
<h4>Email Details</h4>
|
||||
<div class="email-details">
|
||||
<div class="detail-row">
|
||||
<strong>To:</strong> <span id="preview-recipient"></span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<strong>From:</strong> <span id="preview-sender"></span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<strong>Subject:</strong> <span id="preview-subject"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-section">
|
||||
<h4>Email Content Preview</h4>
|
||||
<div id="preview-content" class="email-preview-content">
|
||||
<!-- Email HTML preview will be inserted here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" id="edit-email" class="btn btn-secondary">
|
||||
✏️ Edit Email
|
||||
</button>
|
||||
<button type="button" id="confirm-send" class="btn btn-primary">
|
||||
📧 Send Email
|
||||
</button>
|
||||
<button type="button" id="cancel-preview" class="btn btn-outline">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success/Error Messages -->
|
||||
<div id="message-display" class="message-display" style="display: none;"></div>
|
||||
|
||||
<!-- Campaigns Section -->
|
||||
<section id="campaigns-section" style="display: none;">
|
||||
<div class="campaigns-section-header">
|
||||
<h2>Active Campaigns</h2>
|
||||
<p>Join ongoing campaigns to make your voice heard on important issues</p>
|
||||
</div>
|
||||
<div id="campaigns-grid">
|
||||
<!-- Campaign cards will be dynamically inserted here -->
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>© 2025 <a href="https://bnkops.com/" target="_blank">BNKops</a> Influence Tool. Connect with democracy.</p>
|
||||
<p><small>This tool uses the <a href="https://represent.opennorth.ca" target="_blank">Represent API</a> by Open North to find your representatives.</small></p>
|
||||
<p><small><a href="/terms.html" id="terms-link" target="_blank">Terms of Use & Privacy Notice</a></small></p>
|
||||
|
||||
<div class="preamble" style="text-align: center; padding: 1rem; margin: 1rem 0; background-color: #f5f5f5; border-radius: 8px;">
|
||||
<p>Influence is an open-source platform and the code is available to all at <a href="https://gitea.bnkops.com/admin/changemaker.lite" target="_blank" rel="noopener noreferrer">gitea.bnkops.com/admin/changemaker.lite</a></p>
|
||||
</div>
|
||||
|
||||
<div class="footer-actions">
|
||||
<a href="/login.html" id="login-link" class="btn btn-secondary">Admin Login</a>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- Leaflet JavaScript -->
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
||||
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
|
||||
crossorigin=""></script>
|
||||
|
||||
<script src="js/api-client.js"></script>
|
||||
<script src="js/auth.js"></script>
|
||||
<script src="js/campaigns-grid.js"></script>
|
||||
<script src="js/postal-lookup.js"></script>
|
||||
<script src="js/representatives-display.js"></script>
|
||||
<script src="js/email-composer.js"></script>
|
||||
<script src="js/representatives-map.js"></script>
|
||||
<script src="js/main.js"></script>
|
||||
|
||||
<!-- Check authentication and redirect if logged in -->
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// Check if user is already authenticated
|
||||
if (typeof authManager !== 'undefined') {
|
||||
const isAuth = await authManager.checkSession();
|
||||
if (isAuth && authManager.user) {
|
||||
// Redirect to appropriate dashboard
|
||||
if (authManager.user.isAdmin) {
|
||||
window.location.href = '/admin.html';
|
||||
} else {
|
||||
window.location.href = '/dashboard.html';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update navigation links with APP_URL if needed
|
||||
fetch('/api/config')
|
||||
.then(res => res.json())
|
||||
.then(config => {
|
||||
if (config.appUrl && !window.location.href.startsWith(config.appUrl)) {
|
||||
document.getElementById('terms-link').href = config.appUrl + '/terms.html';
|
||||
document.getElementById('login-link').href = config.appUrl + '/login.html';
|
||||
}
|
||||
})
|
||||
.catch(err => console.log('Config not loaded, using relative paths'));
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
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