# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Overview

Changemaker Lite is a self-hosted political campaign platform built with Docker Compose. It consolidates advocacy email campaigns, geographic mapping, volunteer management, and administration into a single TypeScript stack. The primary domain is `cmlite.org`.

**Current state:** V2 rebuild substantially complete (merged to `main`). Core platform operational with Phases 1-14 complete. See `V2_PLAN.md` for the full roadmap.

**Status Summary:**
- ✅ Phases 1-14 Complete (Foundation through Monitoring + DevOps)
- ✅ Drizzle to Prisma Migration Complete (single-ORM, Feb 2026)
- ✅ Automated Pangolin Setup (one-command tunnel deployment)
- ✅ 3 Security Audits Complete (Feb 2025 + Mar 22/27/30 2026)
- ✅ Social Connections + Calendar (friendship, shared views, availability finder)
- ✅ Payments + Ticketed Events (Stripe integration, check-in scanner)
- ✅ Meeting Planner + Straw Polls (scheduling, voting)
- ✅ SMS Campaign Connector (Termux Android bridge)
- ✅ Docs CMS (blog authoring, access policies, collaboration, version history)
- ✅ User Provisioning Framework (Gitea, Vaultwarden, Listmonk)
- ✅ Granular Admin Roles (9 admin roles + module-specific RBAC)
- ✅ Collaborative Docs Editing (Y.js CRDT + Hocuspocus)
- ✅ Engagement Scoring + EventBus + Gitea SSO
- ✅ MCP Server (Claude Code integration, 27 core + 6 on-demand packs (~65 tools))
- 🚧 Phase 15 (Testing + Polish) - Next

---

## V2 Architecture

### Stack

- **Dual API Architecture**
  - **Express.js API** (TypeScript, port 4000) — Main V2 features with Prisma ORM + PostgreSQL 16
  - **Fastify Media API** (TypeScript, port 4100) — Video library with Prisma ORM (shared DB) ✅ **Migrated from Drizzle (Feb 2026)**
- **React Admin GUI** — Vite + Ant Design + Zustand, port 3000
- **Nginx reverse proxy** — subdomain routing (`*.cmlite.org`)
- **NocoDB v2** — read-only data browser on port 8091
- **Redis** — caching, rate limiting, BullMQ backend, geocoding queue (authenticated)
- **Monitoring Stack** (Docker profile: `monitoring`) — Prometheus, Grafana, Alertmanager, cAdvisor, exporters

### Authentication & Security

- **JWT-based auth:** access tokens (15min) + refresh tokens (7 days, stored in DB)
- **Password policy:** 12+ characters, uppercase, lowercase, digit (enforced at schema level)
- **Initial admin:** Configured via `INITIAL_ADMIN_EMAIL` and `INITIAL_ADMIN_PASSWORD` env vars (auto-created during database seeding)
- **Roles:** `SUPER_ADMIN`, `INFLUENCE_ADMIN`, `MAP_ADMIN`, `BROADCAST_ADMIN`, `CONTENT_ADMIN`, `MEDIA_ADMIN`, `PAYMENTS_ADMIN`, `EVENTS_ADMIN`, `SOCIAL_ADMIN`, `USER`, `TEMP`
- **RBAC:** `requireRole(...roles)`, `requireNonTemp`, `authenticate` middleware; `SUPER_ADMIN` implicitly bypasses all role checks
- **Module-specific role groups** (defined in `api/src/utils/roles.ts`): `INFLUENCE_ROLES`, `MAP_ROLES`, `BROADCAST_ROLES`, `CONTENT_ROLES`, `MEDIA_ROLES`, `PAYMENTS_ROLES`, `EVENTS_ROLES`, `SOCIAL_ROLES`, `SYSTEM_ROLES`, `SCHEDULING_ROLES`
- **User management:** `SUPER_ADMIN` always; other admins need `permissions.canManageUsers: true` for write operations
- **Security:** See Security & Configuration section below + `SECURITY_AUDIT_2025-02-11.md`

### Email Systems

- **BullMQ** — async advocacy email job queue with SMTP
- **Listmonk** — newsletter/marketing campaigns (opt-in sync via `LISTMONK_SYNC_ENABLED`)
- **MailHog** — dev email capture (`EMAIL_TEST_MODE=true`)

### Directory Structure (Annotated)

```
changemaker.lite/
├── api/                           # Dual API servers (Express + Fastify)
│   ├── prisma/
│   │   ├── schema.prisma          # 192 models: User, Campaign, Location, Shift, Payment, Social, etc.
│   │   ├── migrations/            # 50 Prisma migrations (full schema history)
│   │   └── seed.ts                # Admin user, settings, page blocks
│   ├── Dockerfile.media           # Fastify media server container
│   └── src/
│       ├── server.ts              # Express API entry point (port 4000)
│       ├── media-server.ts        # Fastify media API entry point (port 4100)
│       ├── config/
│       │   └── env.ts             # Zod-validated environment config (100+ vars)
│       ├── middleware/            # auth, rbac, rate-limit, validate, error-handler
│       ├── modules/               # 44 modules total
│       │   ├── auth/              # JWT login, register, refresh, logout
│       │   ├── users/             # User CRUD + pagination + search
│       │   ├── settings/          # Site settings singleton (20+ feature flags)
│       │   ├── services/          # Service health checks
│       │   ├── influence/
│       │   │   ├── campaigns/     # Campaign CRUD + public routes
│       │   │   ├── representatives/ # Represent API integration + cache
│       │   │   ├── responses/     # Response wall + moderation + upvoting
│       │   │   ├── postal-codes/  # Postal code cache service
│       │   │   ├── campaign-emails/ # Email tracking + stats
│       │   │   └── email-queue/   # BullMQ queue admin
│       │   ├── map/
│       │   │   ├── locations/     # Location CRUD + geocoding + NAR import
│       │   │   ├── geocoding/     # Multi-provider geocoding (6 providers)
│       │   │   ├── cuts/          # Polygon CRUD + spatial queries
│       │   │   ├── shifts/        # Shift CRUD + signups
│       │   │   ├── canvass/       # Canvassing sessions + visits + routes
│       │   │   ├── tracking/      # GPS tracking sessions (volunteer + admin routes)
│       │   │   └── settings/      # Map settings singleton
│       │   ├── pages/             # Landing page CRUD + block library + public renderer
│       │   ├── email-templates/   # Email template CRUD + rendering
│       │   ├── media/             # Fastify media API (videos, reactions, jobs, analytics)
│       │   ├── social/            # Friendships, challenges, spotlights, referrals
│       │   ├── calendar/          # Calendar layers, items, shared views, availability
│       │   ├── payments/          # Stripe products, donations, subscriptions
│       │   ├── ticketed-events/   # Event ticketing, tiers, check-in
│       │   ├── sms/               # SMS campaigns via Termux Android bridge
│       │   ├── meeting-planner/   # Meeting scheduling with polls
│       │   ├── meetings/          # Meeting agendas, minutes, action items
│       │   ├── polls/             # Straw polls with comments + voting
│       │   ├── docs/              # MkDocs health checks + export routes
│       │   ├── docs-analytics/    # Docs page view tracking
│       │   ├── docs-comments/     # Gitea-backed comments on docs
│       │   ├── people/            # CRM people module
│       │   ├── events/            # Gancio event integration
│       │   ├── newsletter/        # Newsletter management
│       │   ├── listmonk/          # Newsletter sync admin routes
│       │   ├── pangolin/          # Tunnel management (Newt integration)
│       │   ├── rocketchat/        # Rocket.Chat integration
│       │   ├── jitsi/             # Jitsi video conferencing auth
│       │   ├── registry/          # Docker image registry management
│       │   ├── upgrade/           # Auto-upgrade checks + deployment
│       │   ├── gitea-setup/       # Gitea SSO + API token management
│       │   ├── volunteer-invite/  # Invite codes + setup workflows
│       │   ├── gallery-ads/       # Media gallery ads
│       │   ├── homepage/          # Homepage stats + dashboard
│       │   ├── search/            # Cross-module search
│       │   ├── reports/           # Analytics + reporting
│       │   ├── og/                # Open Graph metadata
│       │   ├── qr/                # QR code PNG generation (public)
│       │   ├── dashboard/         # Admin dashboard data
│       │   ├── activity/          # Activity feed
│       │   └── observability/     # Prometheus/Grafana/Alertmanager integration
│       ├── services/              # email, email-queue, geocode-queue, listmonk, pangolin, docker
│       ├── types/                 # express.d.ts (Request augmentation)
│       └── utils/                 # logger (Winston), metrics (prom-client), spatial
│
├── admin/                         # React Admin (Vite + Ant Design + Zustand)
│   └── src/
│       ├── App.tsx                # Main router + route definitions
│       ├── components/
│       │   ├── AppLayout.tsx      # Admin sidebar layout
│       │   ├── PublicLayout.tsx   # Public dark theme layout
│       │   ├── VolunteerLayout.tsx # Volunteer portal layout
│       │   ├── MediaPublicLayout.tsx # Public media gallery layout
│       │   ├── GrapesJSEditor.tsx # Landing page editor wrapper (forwardRef, Ctrl+S)
│       │   ├── map/               # Leaflet map components + controls + drawing modes
│       │   ├── canvass/           # GPS tracking, markers, route, visit recording
│       │   ├── media/             # VideoCard, BulkActions, gallery components
│       │   ├── email-templates/   # Email template components
│       │   └── observability/     # Monitoring components
│       ├── pages/                 # 52 root pages + 8 subdirectories
│       │   ├── influence/         # Campaign moderation, effectiveness, impact stories, straw polls
│       │   ├── map/               # LocationsPage, CutsPage, ShiftsPage, MapSettingsPage, DataQualityDashboard
│       │   ├── media/             # Library, Playlists, Analytics, Gallery Ads, Comment Moderation
│       │   ├── payments/          # Dashboard, Products, Plans, Donations, Subscribers, Settings
│       │   ├── social/            # Dashboard, Graph, Moderation, Referrals, Spotlights, Challenges
│       │   ├── sms/               # Dashboard, Contacts, Campaigns, Conversations, Templates, Setup
│       │   ├── events/            # Ticketed Events, Event Detail, Check-in Scanner
│       │   ├── volunteer/         # Map, Shifts, Routes, Calendar, Friends, Profile, Groups, Achievements
│       │   ├── public/            # Homepage, Campaigns, Map, Events, Media Gallery, Pricing, Donations, Meet
│       │   └── (root)             # Dashboard, Users, Settings, Docs*, MeetingPlanner, Observability, etc.
│       ├── stores/                # 9 Zustand stores (auth, canvass, chat-widget, command-palette, favorites, settings, social, tour, tracking)
│       ├── lib/                   # api.ts, media-api.ts, media-public-api.ts, nav-defaults.ts, service-url.ts, y-textarea.ts
│       ├── hooks/                 # useDebounce, useLocalStorage
│       └── types/                 # api.ts, canvass.ts, media.ts (TypeScript interfaces)
│
├── mcp-server/                    # Claude Code MCP server (27 core + 6 on-demand packs (~65 tools))
├── nginx/                         # Reverse proxy config (subdomain routing + CSP)
├── configs/                       # Prometheus, Grafana, Alertmanager, Pangolin configs
├── scripts/                       # Deployment, backup, upgrade, registry scripts
│   ├── install.sh                 # Curl-friendly installer (downloads tarball + runs config.sh)
│   ├── uninstall.sh               # Remove containers, volumes, and install dir
│   ├── build-and-push.sh          # Build production images → push to Gitea registry
│   ├── build-release.sh           # Package runtime files into release tarball
│   ├── mirror-images.sh           # Mirror third-party images to Gitea
│   ├── upgrade.sh                 # 6-phase upgrade (git or release-tarball mode)
│   ├── upgrade-check.sh           # Check for updates (git or Gitea API)
│   ├── upgrade-watcher.sh         # Systemd bridge for admin GUI upgrades
│   ├── update-env.sh              # Merge new variables from .env.example into existing .env
│   ├── backup.sh / restore.sh     # PostgreSQL + Listmonk + uploads backup/restore
│   ├── validate-env.sh            # Required env variable validation
│   ├── validate-compose-parity.sh # Check docker-compose.yml ↔ docker-compose.prod.yml parity
│   ├── test-deployment.sh         # Post-deploy smoke tests (auth, services, health)
│   ├── register-with-ccp.sh       # Register instance with a Control Panel via invite code
│   ├── ccp-deregister.sh          # Deregister instance from its CCP
│   ├── pangolin-teardown.sh       # Delete Pangolin resources/sites (dry-run by default)
│   ├── gitea-init.sh              # Bootstrap Gitea admin user + SSO app
│   ├── nocodb-init.sh             # Bootstrap NocoDB project + base connection
│   ├── mkdocs-entrypoint.sh       # MkDocs container entrypoint (live + built modes)
│   ├── mkdocs-build-trigger.py    # Trigger MkDocs rebuild from API hooks
│   ├── legacy/                    # Archived Cloudflare tunnel configs (pre-Pangolin)
│   └── systemd/                   # Systemd unit files (backup timer, upgrade watcher)
├── docker-compose.yml             # V2 orchestration (40+ services)
├── docker-compose.prod.yml        # Production (image-only, no source mounts)
├── .env.example                   # All required environment variables
└── V2_PLAN.md                     # Full 14-phase roadmap
```

---

## Quick Start Guide

### Pre-built Install (Production — Recommended)

The fastest way to deploy. No source code, no compilation:

```bash
curl -fsSL https://gitea.bnkops.com/admin/changemaker.lite/raw/branch/main/scripts/install.sh | bash
```

This downloads a ~9MB release tarball, runs the config wizard, and sets `IMAGE_TAG=latest`. Then:

```bash
cd ~/changemaker.lite && docker compose up -d
```

Pre-built images are pulled from `gitea.bnkops.com/admin` (~2 min). Database migrations and seeding run automatically via the API entrypoint. Access the admin GUI at http://localhost:3000.

### Source Install (Development)

1. **Clone repository:**
   ```bash
   git clone <repo-url> changemaker.lite
   cd changemaker.lite
   ```

2. **Create environment file:**
   ```bash
   cp .env.example .env
   # Edit .env and set:
   # - V2_POSTGRES_PASSWORD (strong password)
   # - REDIS_PASSWORD (strong password)
   # - JWT_ACCESS_SECRET (openssl rand -hex 32)
   # - JWT_REFRESH_SECRET (openssl rand -hex 32)
   # - ENCRYPTION_KEY (openssl rand -hex 32, must differ from JWT secrets)
   ```

3. **Start core services:**
   ```bash
   docker compose up -d v2-postgres redis api admin
   ```

4. **Run database migrations:**
   ```bash
   docker compose exec api npx prisma migrate deploy
   docker compose exec api npx prisma db seed
   ```

5. **Access the application:**
   - Admin GUI: http://localhost:3000 (see INITIAL_ADMIN_EMAIL/INITIAL_ADMIN_PASSWORD in .env)
   - API: http://localhost:4000
   - **Change default password immediately**

### Development Workflow

**Starting services:**
```bash
# Core services
docker compose up -d v2-postgres redis api admin

# Include monitoring stack
docker compose --profile monitoring up -d

# Include media API
docker compose up -d media-api
```

**Local development (without Docker):**
```bash
# Terminal 1: API
cd api && npm install && npm run dev

# Terminal 2: Admin
cd admin && npm install && npm run dev

# Terminal 3 (optional): Media API
cd api && npm run dev:media
```

### Accessing Services

| Service | URL | Default Credentials |
|---------|-----|---------------------|
| Admin GUI | http://localhost:3000 | See INITIAL_ADMIN_EMAIL/INITIAL_ADMIN_PASSWORD in .env |
| API | http://localhost:4000 | - |
| Media API | http://localhost:4100 | - |
| NocoDB | http://localhost:8091 | See `NC_ADMIN_EMAIL`/`NC_ADMIN_PASSWORD` in .env |
| Gitea | http://localhost:3030 | See `GITEA_ADMIN_USER`/`GITEA_ADMIN_PASSWORD` in .env |
| MailHog | http://localhost:8025 | - |
| Grafana | http://localhost:3001 | admin / admin |
| Prometheus | http://localhost:9090 | - |
| Listmonk | http://localhost:9001 | See `LISTMONK_WEB_ADMIN_USER`/`PASSWORD` in .env |
| Rocket.Chat | http://localhost:3100 | See RC env vars in .env |
| Excalidraw | http://localhost:8090 | - |
| Vaultwarden | http://localhost:8093 | See `VAULTWARDEN_ADMIN_TOKEN` in .env |

### Feature Flags

Most features are toggled via **SiteSettings** in the database (admin Settings page). Some also have `.env` overrides:

```bash
# .env feature flags (env-level)
ENABLE_MEDIA_FEATURES=true         # Media manager
ENABLE_HLS_TRANSCODE=true          # HLS adaptive bitrate transcoding (off by default)
ENABLE_PAYMENTS=true               # Stripe integration
ENABLE_SMS=true                    # SMS campaigns
ENABLE_CHAT=true                   # Rocket.Chat
ENABLE_MEET=true                   # Jitsi meetings
LISTMONK_SYNC_ENABLED=true        # Newsletter sync
EMAIL_TEST_MODE=true               # MailHog vs SMTP
```

**Database feature flags (SiteSettings):** `enableInfluence`, `enableMap`, `enableNewsletter`, `enableLandingPages`, `enableMediaFeatures`, `enablePayments`, `enableGalleryAds`, `enableChat`, `enableEvents`, `enableDocsComments`, `enableSms`, `enablePeople`, `enableSocial`, `enableMeet`, `enableMeetingPlanner`, `enableTicketedEvents`, `enableSocialCalendar`, `enablePolls`, `enableDocsCollaboration`, `enableUserProvisioning`

---

## Development Commands

The user likes to use Docker - recereating services as if in production. 

### API Development
```bash
cd api && npm run dev                    # Express dev server (port 4000)
cd api && npm run dev:media              # Fastify media dev server (port 4100)
cd api && npx tsc --noEmit               # Type-check
cd api && npx prisma migrate dev         # Run/create Prisma migrations
cd api && npx prisma studio              # Browse database
```

### Admin Development
```bash
cd admin && npm run dev                  # Vite dev server (port 3000)
cd admin && npx tsc --noEmit             # Type-check
cd admin && npm run build                # Production build
```

### Docker Operations
```bash
# Start services
docker compose up -d v2-postgres redis api admin
docker compose up -d media-api
docker compose --profile monitoring up -d

# View logs
docker compose logs -f api
docker compose logs -f media-api

# Database operations
docker compose exec api npx prisma migrate dev

# Stop services
docker compose down
```

### Registry & Release Operations
```bash
# Build production images and push to Gitea registry
./scripts/build-and-push.sh --services api,admin,media-api,nginx
./scripts/build-and-push.sh --no-push                  # Build only, no push (verify)

# Mirror third-party images to Gitea
./scripts/mirror-images.sh                             # Core images (postgres, redis, etc.)
./scripts/mirror-images.sh --all                       # Include heavy images (RC, Jitsi, n8n)

# Build release tarball (for pre-built installs — run AFTER build-and-push)
./scripts/build-release.sh --tag v2.1.0                # Creates releases/changemaker-lite-v2.1.0.tar.gz
./scripts/build-release.sh --tag v2.1.0 --upload       # Also upload to Gitea Releases API
./scripts/build-release.sh --dry-run                   # Preview tarball contents

# Use registry images in upgrade (source installs)
./scripts/upgrade.sh --use-registry --force --skip-backup

# Install from tarball (end-user one-liner)
curl -fsSL https://gitea.bnkops.com/admin/changemaker.lite/raw/branch/main/scripts/install.sh | bash
```

**Two compose files:**
- `docker-compose.yml` — Development: includes `build:` blocks and `./api:/app` source mounts
- `docker-compose.prod.yml` — Production: `image:` only, no source mounts, `IMAGE_TAG:-latest`

Release tarballs ship `docker-compose.prod.yml` as the compose file. Source installs use `docker-compose.yml`.

**Note:** gitea.bnkops.com must use Pangolin tunnel (not Cloudflare proxy) for large image layers (>100MB). See `docs/REGISTRY_GUIDE.md`.

### Testing & Backup
```bash
# Media API tests
cd api && ./test-media-api.sh

# Backup (PostgreSQL + Listmonk + uploads)
./scripts/backup.sh

# Type-check all projects
cd api && npx tsc --noEmit && cd ../admin && npx tsc --noEmit
```

### API Testing Credentials & Login

**Test admin account:** `admin@bnkops.ca` / `ChangeMe2025!` (SUPER_ADMIN role)

**Reliable login method (avoids shell `!` escaping issues):**

1. Write the JSON body to a file using the **Write tool** (NOT echo/printf — the `!` gets backslash-escaped by bash):
   ```
   Write /tmp/login.json → {"email":"admin@bnkops.ca","password":"ChangeMe2025!"}
   ```
2. Use `curl -d @/tmp/login.json`:
   ```bash
   curl -s -X POST http://localhost:4002/api/auth/login \
     -H "Content-Type: application/json" -d @/tmp/login.json
   ```
3. Extract token and use for authenticated requests:
   ```bash
   TOKEN=$(curl -s -X POST http://localhost:4002/api/auth/login \
     -H "Content-Type: application/json" -d @/tmp/login.json \
     | python3 -c "import sys,json; print(json.load(sys.stdin)['accessToken'])")
   curl -s http://localhost:4002/api/some-endpoint -H "Authorization: Bearer $TOKEN"
   ```

**Port mapping:** API container port 4000 → host port **4002**, Admin port 3000 → host port **3002**

**Important:** The `!` character in `ChangeMe2025!` triggers bash history expansion. NEVER pass this password directly in bash command strings. Always use the Write-tool-to-file approach above.

---

## Core Modules Reference

### Auth & Users

**Files:**
- `api/src/modules/auth/` — JWT login, register, refresh, logout
- `api/src/modules/users/` — User CRUD + pagination + search
- `api/src/middleware/auth.ts` — JWT verification + RBAC
- `admin/src/stores/auth.store.ts` — Zustand auth state + token persistence
- `admin/src/lib/api.ts` — Axios with 401 refresh interceptor

**Features:** JWT access/refresh tokens, bcrypt passwords (12+ chars), role-based access control, user enumeration prevention, rate limiting

### Influence Module (Advocacy Campaigns)

**Files:**
- `api/src/modules/influence/campaigns/` — Campaign CRUD + public routes
- `api/src/modules/influence/representatives/` — Represent API client + cache
- `api/src/modules/influence/responses/` — Response wall + moderation + upvoting
- `api/src/services/email-queue.service.ts` — BullMQ queue + worker
- `admin/src/pages/CampaignsPage.tsx` — Campaign management
- `admin/src/pages/public/CampaignPage.tsx` — Public campaign page

**Features:** Postal code → representative lookup, email campaigns, response wall with moderation, BullMQ async email queue

**Routes:**
- Admin: `/app/influence/campaigns`, `/app/influence/responses`, `/app/influence/email-queue`
- Public: `/campaigns`, `/campaigns/:id`, `/responses/:campaignId`

### Map Module (Locations & Canvassing)

**Files:**
- `api/src/modules/map/locations/` — Location CRUD + geocoding + NAR import
- `api/src/modules/map/geocoding/geocoding.service.ts` — Multi-provider geocoding (6 providers)
- `api/src/modules/map/cuts/` — Polygon CRUD + spatial queries
- `api/src/modules/map/shifts/` — Shift CRUD + signups
- `api/src/modules/map/canvass/` — Canvassing sessions + visits + routes
- `api/src/modules/map/tracking/` — GPS tracking sessions (volunteer + admin routes)
- `api/src/utils/spatial.ts` — Point-in-polygon, haversine, bounds, centroids
- `admin/src/pages/LocationsPage.tsx` — Location CRUD + CSV + geocoding
- `admin/src/pages/CutsPage.tsx` — Cut table + map drawing editor
- `admin/src/pages/CanvassDashboardPage.tsx` — Admin canvass overview
- `admin/src/pages/volunteer/VolunteerMapPage.tsx` — Full-screen GPS canvass map

**Features:** Multi-provider geocoding, NAR 2025 import (Canadian electoral data), polygon cuts, volunteer shifts, canvassing system with GPS tracking, walking route algorithm, printable walk sheets

**Routes:**
- Admin: `/app/map/locations`, `/app/map/cuts`, `/app/map/shifts`, `/app/canvass/dashboard`
- Public: `/map`, `/shifts`
- Volunteer: `/volunteer/canvass/:cutId`, `/volunteer/assignments`, `/volunteer/activity`

### Landing Pages & Email Templates

**Files:**
- `api/src/modules/pages/` — Landing page CRUD + block library (3 route files)
- `api/src/modules/email-templates/` — Email template CRUD + rendering
- `admin/src/components/GrapesJSEditor.tsx` — GrapesJS wrapper (forwardRef, Ctrl+S)
- `admin/src/pages/PageEditorPage.tsx` — Full-screen page editor
- `admin/src/pages/EmailTemplateEditorPage.tsx` — Email template editor

**Features:** GrapesJS WYSIWYG editor, page/template CRUD, MkDocs export (Jinja2 Material overrides), public renderer, desktop-only editor warning

**Routes:**
- Admin: `/app/pages`, `/app/pages/:id/edit`, `/app/email-templates`
- Public: `/p/:slug`

### Media Manager (Dual API)

**Files:**
- `api/src/modules/media/` — Fastify media API (videos, reactions, jobs, analytics)
- `api/src/modules/media/services/` — FFprobe, thumbnail, **HLS transcode** services
- `api/src/modules/media/routes/` — Video CRUD, actions, schedule, analytics, tracking, upload, **HLS streaming**
- `api/src/services/video-schedule-queue.service.ts` — BullMQ queue for scheduled publishing
- `api/src/services/hls-transcode-queue.service.ts` — BullMQ queue for HLS adaptive bitrate transcoding (concurrency 1, in-process worker)
- `api/src/modules/media/routes/hls.routes.ts` — Master/variant playlist + segment serving with signed URLs
- `api/scripts/backfill-hls.ts` — Backfill HLS for pre-existing videos (`npm run backfill:hls`)
- `admin/src/lib/use-hls.ts` — React hook attaching hls.js (Chrome/FF/Edge) or native (Safari/iOS)
- `admin/src/lib/media-api.ts` — Dedicated axios instance for Media API
- `admin/src/pages/media/LibraryPage.tsx` — Video library with quick actions + calendar
- `admin/src/pages/media/AnalyticsDashboardPage.tsx` — Global analytics dashboard
- `admin/src/pages/media/SharedMediaPage.tsx` — Public gallery admin
- `admin/src/pages/public/MediaGalleryPage.tsx` — Public video gallery
- `admin/src/components/media/` — VideoCard, VideoActions, modals, charts

**Features:** Video CRUD with FFprobe metadata, quick actions, scheduled publishing (BullMQ + timezones), analytics (GDPR-compliant), public tracking endpoints, keyboard shortcuts, **HLS adaptive bitrate streaming (360p/720p/1080p, MP4 fallback)**.

**HLS adaptive bitrate streaming:**
- On upload, a BullMQ `hls-transcode` job runs FFmpeg to produce a master playlist + 3 keyframe-aligned variants under `/media/local/hls/{videoId}/`. Concurrency is 1; the worker runs in-process with the media-api Fastify server.
- Player prefers HLS over MP4 when `Video.hlsStatus === 'READY'`. MP4 streaming routes stay as the always-on fallback for un-transcoded videos and for hover-preview cards (where 200ms hls.js init defeats the UX — `PublicVideoCard` stays MP4).
- `useHls()` hook lazy-imports hls.js (~75 KB gzipped, never enters main bundle), uses native HLS on Safari/iOS, gives up after 2 NETWORK_ERROR retries so the MP4 fallback can kick in.
- Manifest URLs are HMAC-signed (`?sig=&exp=&uid=`) per existing `signMediaPath()` pattern. Variant playlists rewrite their segment URIs server-side at fetch time so each segment carries a fresh signature.
- Feature flag: `ENABLE_HLS_TRANSCODE` (default `false`). When off, uploads are tagged `SKIPPED` and the player falls back to MP4 — fully reversible. The worker stays registered so existing `PENDING` jobs still process if the flag flips back on.
- Backfill: `docker compose exec api npm run backfill:hls` enqueues all `hlsStatus IS NULL` videos. Bypasses the flag (operator opt-in). At ~2 min per 1080p video, throughput is ~30/hour.

**Routes:**
- Admin: `/app/media/library`, `/app/media/analytics`, `/app/media/shared`, `/app/media/jobs`
- Public: `/gallery`, `/gallery/watch/:id`, `/media/:id` (legacy)
- Public gallery uses `MediaPublicLayout` (purple theme, optional auth)

### Services & Integrations

**Listmonk Newsletter Sync:**
- `api/src/services/listmonk.client.ts` — Listmonk REST API client (native fetch)
- `api/src/services/listmonk-sync.service.ts` — Sync participants/locations → lists
- `admin/src/pages/ListmonkPage.tsx` — Newsletter sync management
- Opt-in sync: `LISTMONK_SYNC_ENABLED=true`

**Pangolin Tunnel Management:**
- `api/src/services/pangolin.client.ts` — Pangolin Integration API client
- `api/src/modules/pangolin/pangolin.routes.ts` — Tunnel management routes (includes `/setup-automated`)
- `admin/src/pages/PangolinPage.tsx` — Setup wizard + status dashboard + automated setup button
- `scripts/register-with-ccp.sh` — Register this instance with a Control Panel (CCP) using an invite code
- `scripts/pangolin-teardown.sh` — Delete all Pangolin resources/sites for an org (dry-run by default, idempotent)
- `scripts/ccp-deregister.sh` — Deregister instance from its CCP
- `configs/pangolin/resources.yml` — Central resource definitions (12 services)
- Newt container integration (Cloudflare alternative)
- **Automated setup:** One-command deployment via CCP registration (creates site, updates .env, restarts Newt)
- **Continuous sync:** Hourly resource sync via nginx cron job

**MkDocs + Code Server:**
- `api/src/modules/docs/docs.routes.ts` — Health checks + export routes
- `admin/src/pages/DocsPage.tsx` — MkDocs export management
- `admin/src/pages/CodeEditorPage.tsx` — Code Server management
- Embedded iframes in admin (CSP `frame-ancestors` for embedding)

**Mini QR Service:**
- `api/src/modules/qr/qr.routes.ts` — QR code PNG generation (public, no auth)
- `admin/src/pages/MiniQRPage.tsx` — Mini QR iframe
- Used by walk sheets + cut exports

### Observability & Monitoring

**Files:**
- `api/src/modules/observability/observability.routes.ts` — Prometheus/Grafana/Alertmanager integration
- `api/src/utils/metrics.ts` — 12 custom `cm_*` Prometheus metrics
- `admin/src/pages/ObservabilityPage.tsx` — Monitoring dashboard (3 tabs)
- `admin/src/pages/DataQualityDashboardPage.tsx` — Geocoding quality metrics
- `configs/prometheus/` — Scrape targets, alert rules
- `configs/grafana/` — 3 pre-configured dashboards

**Features:** 12 custom `cm_*` metrics (API uptime, queue size, sessions, etc.), HTTP request metrics, external service health gauges, 3 Grafana dashboards, alert rules, auto-start banner

**Routes:**
- Admin: `/app/observability`, `/app/map/data-quality`
- Direct: `localhost:9090` (Prometheus), `localhost:3001` (Grafana)

---

## Port Reference

| Port | Service | Notes |
|------|---------|-------|
| **Core Services** | | |
| 3000 | Admin GUI | Vite dev / React production |
| 4000 | Express API | Main V2 API (Prisma) |
| 4100 | Fastify Media API | Video library (Prisma) |
| 5433 | V2 PostgreSQL | Localhost (container: 5432) |
| 6379 | Redis | Cache, rate limit, BullMQ |
| **Supporting Services** | | |
| 3001 | Grafana | Metrics visualization |
| 3010 | Homepage | Service dashboard |
| 3030 | Gitea | Git hosting + SSO |
| 3100 | Rocket.Chat | Team chat (embed proxy) |
| 4001 | MkDocs Site | Served docs |
| 4003 | MkDocs Dev | Live preview |
| 5432 | Listmonk PostgreSQL | Listmonk DB |
| 5678 | n8n | Workflow automation |
| 8025 | MailHog | Email capture (dev) |
| 8089 | Mini QR | QR generator |
| 8090 | Excalidraw | Collaborative whiteboard |
| 8091 | NocoDB | Data browser |
| 8092 | Gancio | Event management |
| 8093 | Vaultwarden | Password manager |
| 8443 | Jitsi Web | Video conferencing |
| 8885 | Mini QR Proxy | Iframe-friendly |
| 8888 | Code Server | Web IDE |
| 9001 | Listmonk | Newsletter platform |
| **Monitoring** (profile: `monitoring`) | | |
| 8080 | cAdvisor | Container metrics |
| 8889 | Gotify | Notifications |
| 9090 | Prometheus | Metrics collection |
| 9093 | Alertmanager | Alert routing |
| 9100 | Node Exporter | Host metrics |
| 9121 | Redis Exporter | Redis metrics |

---

## Nginx Routing

| Subdomain | Target | Purpose |
|-----------|--------|---------|
| `app.cmlite.org` | Admin (3000) | **All application routes** (admin + public pages, campaigns, map, shifts, media) |
| `api.cmlite.org` | Express (4000) | Main API |
| `media.cmlite.org` | Fastify (4100) | Media API |
| `db.cmlite.org` | NocoDB (8091) | Data browser |
| `docs.cmlite.org` | MkDocs (4003) | Docs site |
| `code.cmlite.org` | Code Server (8888) | Web IDE |
| `n8n.cmlite.org` | n8n (5678) | Workflow automation |
| `git.cmlite.org` | Gitea (3030) | Git hosting + SSO |
| `home.cmlite.org` | Homepage (3010) | Dashboard |
| `grafana.cmlite.org` | Grafana (3001) | Metrics viz |
| `listmonk.cmlite.org` | Listmonk (9001) | Newsletters |
| `qr.cmlite.org` | Mini QR (8089) | QR generator |
| `chat.cmlite.org` | Rocket.Chat (3100) | Team chat |
| `meet.cmlite.org` | Jitsi (8443) | Video conferencing |
| `events.cmlite.org` | Gancio (8092) | Event management |
| `draw.cmlite.org` | Excalidraw (8090) | Collaborative whiteboard |
| `vault.cmlite.org` | Vaultwarden (8093) | Password manager |
| `mail.cmlite.org` | MailHog (8025) | Email capture (dev) |
| `cmlite.org` | MkDocs Static (4004) | **Documentation/marketing site only** |

**Clean separation:** Root domain (`${DOMAIN}`) serves MkDocs documentation site. All application functionality (admin GUI, public campaigns, map, shifts, media gallery) is accessible via `app.${DOMAIN}` subdomain. This provides clear separation between public documentation and the application.

---

## Common Patterns

**Note:** Below are the key development patterns for this project.

### API Router Structure
- Service layer (`*.service.ts`) — business logic, database queries
- Routes (`*.routes.ts`) — Express router, middleware, validation
- Schemas (`*.schemas.ts`) — Zod validation schemas
- Split admin/public routes when needed (e.g., `campaigns.routes.ts` + `campaigns-public.routes.ts`)

### Authentication Middleware
- `authenticate` — requires any logged-in user
- `requireRole(...roles)` — requires specific role(s)
- `requireNonTemp` — blocks TEMP users
- Login redirects: ADMIN_ROLES → `/app`, USER/TEMP → `/volunteer`

### Frontend Architecture
- Admin pages: `admin/src/pages/` + subdirs (AppLayout)
- Public pages: `admin/src/pages/public/` (PublicLayout, dark theme)
- Volunteer pages: `admin/src/pages/volunteer/` (VolunteerLayout)
- Zustand stores (9): auth, canvass, chat-widget, command-palette, favorites, settings, social, tour, tracking
- API clients: `{ api }` from `lib/api.ts`, `mediaApi` from `lib/media-api.ts`

### Database ORM
- **Prisma** (both APIs): 192 models in single `schema.prisma`. Use `UncheckedCreateInput`/`UncheckedUpdateInput` for foreign keys, `Prisma.InputJsonValue` for JSON arrays

### Prisma Migration Workflow
- **Always use `prisma migrate dev`** for schema changes (not `prisma db push`) — `db push` applies changes directly but doesn't create migration files, causing drift
- **Migration history:** 50 migrations in `api/prisma/migrations/` fully cover the schema
- **Production deploys:** Use `prisma migrate deploy` (not `migrate dev`)

### Key Gotchas
- **Prisma migrations:** Never use `db push` — always `migrate dev` to keep history in sync
- Nginx media API block must come BEFORE general API block
- `IMAGE_TAG=local` (default) never pulls from registry; set to SHA or `latest` for pre-built images
- **Release vs source installs:** Detected by `VERSION` file + absence of `.git/`; release uses `docker-compose.prod.yml`, source uses `docker-compose.yml`
- **`api/dist/` is gitignored** — never commit; if root-owned from container builds, fix with `chown`
- **`!` in passwords** triggers bash history expansion — use Write tool to write JSON to file, then `curl -d @file`
- **Port mappings:** API container 4000 → host 4002, Admin container 3000 → host 3002
- **BullMQ** needs its own Redis connections (pass URL string, not shared ioredis instance)
- **Public pages** use `axios` directly (no auth interceptor), admin pages use `{ api }` from lib
- **Prisma JSON fields:** typed arrays need `as unknown as Prisma.InputJsonValue` cast
- **nginx conf.d files** have `.template` counterparts used by envsubst at startup

---

## Security & Configuration

### Security Audits
Four security audits completed. See audit reports for full details:
- **Feb 2025:** 13 findings (password policy, rate limits, token rotation, XSS prevention). `SECURITY_AUDIT_2025-02-11.md`
- **Mar 22 2026:** JWT algorithm lockdown, invite secret separation, webhook hardening, CSV injection, QR DoS
- **Mar 27 2026:** 33 findings (30 fixed) — IDOR, XSS, path traversal, MongoDB auth, SSTI, open redirect
- **Mar 30 2026:** 19 findings — IDOR action items/ticketed events, nginx rate limit, JWT secret reuse

**Key security features:**
- Password policy: 12+ chars, uppercase, lowercase, digit (schema-enforced)
- Rate limits on auth endpoints (10/min per IP) + nginx rate limiting
- Refresh token rotation (atomic Prisma transaction)
- JWT algorithm locked to HS256, separate invite secret
- User enumeration prevention (401 not 404)
- Redis authentication required
- XSS/injection prevention (HTML escaping, DOMPurify, SSTI protection)
- Path traversal protection (resolve + startsWith checks)
- Encryption key for DB secrets (`ENCRYPTION_KEY` required in all environments)
- Nginx security headers (HSTS, Permissions-Policy, CSP, X-Forwarded-For)
- MongoDB keyfile authentication
- httpOnly cookies for refresh tokens

### Required Environment Variables
See `.env.example` for all 100+ variables. Critical ones:
- `V2_POSTGRES_PASSWORD`, `REDIS_PASSWORD`
- `JWT_ACCESS_SECRET`, `JWT_REFRESH_SECRET`
- `ENCRYPTION_KEY` (must differ from JWT secrets)
- `LISTMONK_SYNC_ENABLED` (opt-in newsletter sync)
- `EMAIL_TEST_MODE` (MailHog vs SMTP)
- `ENABLE_MEDIA_FEATURES` (media manager)

### Production Deployment
- **Tunneling:** Pangolin with Newt container (Cloudflare alternative)
- **SSL/TLS:** Handled by tunnel provider (Pangolin/Cloudflare)
- **Docker Networking:** All containers share `changemaker-lite` bridge network, reference by container name
- **Monitoring:** Enable with `docker compose --profile monitoring up -d`
- **Backups:** Run `./scripts/backup.sh` (PostgreSQL + Listmonk + uploads, optional S3 upload)

#### Production CORS Configuration

When deploying to a production domain via Pangolin tunnel, you MUST update the `.env` file to include the production domain in `CORS_ORIGINS`:

```bash
# Example for cmlite.org
CORS_ORIGINS=http://app.cmlite.org,https://app.cmlite.org,http://localhost:3000,http://localhost

# Also set production mode
NODE_ENV=production
```

Without this, API requests from the production domain will fail CORS validation. After updating `.env`, restart the API container:

```bash
docker compose restart api
```

---

## Troubleshooting

### Production 403/302 Errors - Pangolin Resources

**Symptom:** All API endpoints return 302 redirects to Pangolin authentication page, or 403 Forbidden errors.

**Root Cause:** Pangolin tunnel resources are configured with authentication enabled (default behavior).

**Fix:** Log in to your Pangolin dashboard and edit each resource:
1. Navigate to **Resources** → **Public**
2. For each resource (app, api, media, docs, etc.), click **Edit**
3. Change **Authentication** setting to **"Not Protected"** (or "Public Access"/"No Authentication")
4. Save changes

**Critical resources to fix first:**
- `api.${DOMAIN}` - Main API (all endpoints fail without this)
- `app.${DOMAIN}` - Admin GUI + public pages
- `media.${DOMAIN}` - Media API

**Verification:**
```bash
# Should return JSON, NOT a 302 redirect
curl https://api.cmlite.org/api/health
```

### CORS Errors in Production

**Symptom:** Browser console shows CORS errors when accessing production domain.

**Fix:** Add production domain to `CORS_ORIGINS` in `.env` file (see Production CORS Configuration above).

### API Works Locally But Not Via Tunnel

Check in order:
1. **Newt container running:** `docker compose ps newt`
2. **Newt connected:** `docker compose logs newt --tail 50` (should show successful connection)
3. **Environment variables set:** `PANGOLIN_SITE_ID`, `PANGOLIN_NEWT_ID`, `PANGOLIN_NEWT_SECRET` in `.env`
4. **Pangolin resources configured:** All resources set to "Not Protected"
5. **Nginx running:** `docker compose ps nginx`

### Database/Redis Connection Failures
Check container status (`docker compose ps`), verify credentials in `.env`, check logs (`docker compose logs <service> --tail 50`). Test DB: `docker compose exec api npx prisma db execute --stdin <<< "SELECT 1"`. Test Redis: `docker compose exec redis-changemaker redis-cli -a $REDIS_PASSWORD ping`.

### Video Stuck in HLS PROCESSING / FAILED with EACCES
**Symptom:** A video shows `hlsStatus = 'PROCESSING'` for many minutes; or `'FAILED'` with `hls_transcode_error LIKE '%EACCES%'`. Player keeps falling back to MP4.

Check in order:
1. **First-run perms.** If `hls_transcode_error` contains `EACCES: permission denied, mkdir '/media/local/hls/<id>'`, the bind-mount got created as `root:root` but the Node process runs as `node` (UID 1000). One-time fix:
   ```
   docker compose exec -u 0 media-api chown -R 1000:1000 /media/local/hls
   ```
   Then reset and re-enqueue:
   ```
   docker compose exec -T v2-postgres psql -U changemaker -d changemaker_v2 -c "UPDATE videos SET hls_status = NULL, hls_transcode_error = NULL WHERE hls_status = 'FAILED';"
   docker compose exec api npm run backfill:hls
   ```
2. **Worker running:** `docker compose logs media-api --tail 100 | grep -i hls` — expect `[hls]` lines for the queue worker startup and per-job progress.
3. **FFmpeg in container:** `docker compose exec media-api ffmpeg -version` — should print FFmpeg version. (Already in `Dockerfile.media`.)
4. **Queue depth:** `docker compose exec redis-changemaker redis-cli -a $REDIS_PASSWORD LLEN bull:hls-transcode:wait` — non-zero means jobs are queued behind a slow one.
5. **Disk space at output:** `docker compose exec media-api df -h /media/local/hls` — transcoding can consume several GB per video.
6. **Failure record:** `docker compose exec api npx prisma studio` → Video table → check `hlsTranscodeError`.

To force a re-transcode of a failed video, set `hlsStatus = NULL` in the DB and run `npm run backfill:hls`.

---

## V1 Reference (Legacy)

V1 code has been removed from the repo. History preserved as `v1-archive` git tag. `docker-compose.v1.yml` remains as reference only.

---

## Key Configuration Files

### Infrastructure
- `docker-compose.yml` — Development orchestration (build blocks + source mounts, 40+ services)
- `docker-compose.prod.yml` — Production orchestration (image-only, no source mounts, `IMAGE_TAG:-latest`)
- `.env` / `.env.example` — Environment variables (100+ vars)
- `config.sh` — Interactive setup wizard (14 steps, release-mode aware)

### Database
- `api/prisma/schema.prisma` — Main schema (192 Prisma models)
- `api/prisma/migrations/` — 50 migration files (full schema history)
- `api/prisma/seed.ts` — Database seeding

### Nginx
- `nginx/nginx.conf` — Global config + security headers
- `nginx/conf.d/default.conf` — Subdomain routing (12+ subdomains)
- `nginx/conf.d/api.conf` — API reverse proxy (Express + Fastify)
- `nginx/conf.d/services.conf` — Service proxies

### Monitoring
- `configs/prometheus/prometheus.yml` — Scrape targets + global config
- `configs/prometheus/alerts.yml` — Alert rules (12 rules)
- `configs/grafana/` — 3 pre-configured dashboards
- `configs/alertmanager/alertmanager.yml` — Alert routing

### Documentation
- `CLAUDE.md` — Project-wide instructions (this file)
- `V2_PLAN.md` — Full 14-phase roadmap
- `SECURITY_AUDIT_2025-02-11.md` — Initial security audit report
- `.mcp.json` — MCP server configuration for Claude Code
