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:
bunker-admin 2026-02-17 15:42:32 -07:00
parent 58dc1942ec
commit 99a6abab06
1511 changed files with 14551 additions and 1625410 deletions

View File

@ -174,6 +174,7 @@ USER_NAME=coder
# --- Homepage ---
HOMEPAGE_PORT=3010
HOMEPAGE_EMBED_PORT=8887
HOMEPAGE_VAR_BASE_URL=http://localhost
# --- Mini QR ---

View File

@ -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)

View File

@ -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`

View File

@ -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

View File

@ -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",

View File

@ -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",

View File

@ -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>`;
}

View File

@ -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>
</>
);
}

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

View File

@ -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';

View File

@ -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;

View File

@ -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;">&#9654;</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 &bull; Quality &bull; 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;">&#9654; 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>
`;

View File

@ -114,7 +114,6 @@ function FullscreenInvalidator() {
}
function MapEventsHandler({
onMove,
setMapInstance,
setCurrentZoom
}: {

View File

@ -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>

View File

@ -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({

View File

@ -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[]>([]);

View File

@ -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(() => {

View File

@ -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,

View File

@ -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"

View File

@ -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>
);
}

View File

@ -5,7 +5,6 @@ import {
PlayCircleOutlined,
BarChartOutlined,
CopyOutlined,
SwapOutlined,
DownloadOutlined,
PictureOutlined,
ReloadOutlined,

View File

@ -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,

View File

@ -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';

View File

@ -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',

View File

@ -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';

View File

@ -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(() => {

View File

@ -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',

View File

@ -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 {

View File

@ -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;

View 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 };
}

View File

@ -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);

View File

@ -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}`;

View File

@ -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 }: {

View File

@ -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

View File

@ -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;

View File

@ -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);

View File

@ -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,

View File

@ -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}

View File

@ -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 () => {

View File

@ -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;

View File

@ -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;

View File

@ -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));
});

View File

@ -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 ---

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;');
}
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 &rarr;</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 ? ` &bull; ${quality}` : ''} &bull; ${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;">
&#9654; 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

View File

@ -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) {

View File

@ -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('')

View File

@ -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.)

View File

@ -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' });

View File

@ -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' });

View File

@ -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' });

View File

@ -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({

View File

@ -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);

View File

@ -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';

View File

@ -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,
},
});

View File

@ -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;

View File

@ -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(),

View File

@ -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();

View File

@ -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',

View File

@ -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

View File

@ -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 },

View File

@ -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})`);

View File

@ -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,

View File

@ -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',
});
},
);

View File

@ -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(' &bull; ');
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;">
&#9654; 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)');
}

2340
config.sh

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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:

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 codes 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 representatives 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 thats not on the official legislature site |
| district\_id | 24013 | If theres 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 representatives 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

View File

@ -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`

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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"]

View File

@ -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

View File

@ -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();

View File

@ -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();

View File

@ -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'
});
}
};

View File

@ -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

View File

@ -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();

View File

@ -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
};

View File

@ -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
};

View File

@ -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;

View File

@ -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

View File

@ -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">&times;</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>&copy; 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>

View File

@ -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

View File

@ -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>

View File

@ -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

View File

@ -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">&times;</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">&times;</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>&copy; 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