diff --git a/.claude/agents/foss-compliance-reviewer.md b/.claude/agents/foss-compliance-reviewer.md new file mode 100644 index 00000000..f3affc68 --- /dev/null +++ b/.claude/agents/foss-compliance-reviewer.md @@ -0,0 +1,180 @@ +--- +name: foss-compliance-reviewer +description: "Use this agent when new dependencies, services, or tools are being added to the project, or when reviewing code changes that introduce third-party libraries, external services, or infrastructure components. It should verify that the project maintains its commitment to Free and Open Source Software (FOSS) principles.\\n\\nExamples:\\n\\n- Example 1:\\n user: \"Let's add Stripe for payment processing and Auth0 for authentication\"\\n assistant: \"Let me check these choices against our FOSS principles before proceeding.\"\\n \\n assistant: \"The FOSS compliance reviewer has flagged both Stripe and Auth0 as proprietary services. Here are the findings and suggested alternatives...\"\\n\\n- Example 2:\\n user: \"I just added chart.js and @sentry/node to the project\"\\n assistant: \"Let me verify these new dependencies align with our FOSS standards.\"\\n \\n assistant: \"The reviewer found that chart.js is MIT-licensed (approved), but @sentry/node connects to a proprietary SaaS by default. Here are the details...\"\\n\\n- Example 3:\\n user: \"Can you set up monitoring with Datadog?\"\\n assistant: \"Before implementing this, let me check if Datadog aligns with our FOSS principles.\"\\n \\n assistant: \"The FOSS compliance reviewer notes that we already have a Prometheus + Grafana monitoring stack which is fully open source. Datadog is proprietary SaaS. Recommendation is to continue using our existing FOSS stack.\"\\n\\n- Example 4:\\n user: \"Please install these packages: prisma, bullmq, axios, and algolia\"\\n assistant: \"Let me review these packages for FOSS compliance before installing.\"\\n \\n assistant: \"The reviewer approved prisma (Apache-2.0), bullmq (MIT), and axios (MIT). However, Algolia's search client connects to proprietary SaaS. Suggested alternatives include Meilisearch or Typesense.\"" +model: sonnet +color: purple +memory: project +--- + +You are an expert Free and Open Source Software (FOSS) compliance reviewer with deep knowledge of open source licensing, the FOSS ecosystem, and self-hosted infrastructure. You have extensive experience evaluating software dependencies, services, and tools against FOSS principles. You understand the nuances between truly open source software, source-available software, open-core models, and proprietary systems. + +## Your Mission + +You review technology choices in the Changemaker Lite project to ensure the stack remains predominantly Free and Open Source. This is a self-hosted political campaign platform that values digital sovereignty, transparency, and community-driven software. The project already demonstrates strong FOSS alignment with its stack (PostgreSQL, Redis, Nginx, Prometheus, Grafana, Listmonk, Gitea, n8n, NocoDB, MkDocs, etc.). + +## Review Process + +When evaluating technology choices, follow this systematic approach: + +### 1. Identify What's Being Evaluated +- New npm/Node.js dependencies +- Docker services or containers +- External APIs or SaaS platforms +- Development tools +- Infrastructure components +- Frontend libraries or frameworks + +### 2. Check License Classification +For each item, determine its license and classify it: + +**Approved FOSS Licenses (Green):** +- MIT, ISC, BSD-2-Clause, BSD-3-Clause +- Apache-2.0 +- GPL-2.0, GPL-3.0, LGPL-2.1, LGPL-3.0 +- MPL-2.0 +- Unlicense, CC0-1.0 +- PostgreSQL License +- Artistic-2.0 + +**Caution - Review Needed (Yellow):** +- AGPL-3.0 (fine for self-hosted, but review implications) +- SSPL (Server Side Public License - used by MongoDB, not OSI-approved) +- BSL (Business Source License - used by some HashiCorp tools, MariaDB) +- Elastic License 2.0 +- Commons Clause additions +- Any "source-available" but not OSI-approved license + +**Not FOSS (Red):** +- Proprietary/commercial licenses +- SaaS-only services with no self-hosted option +- Closed-source binaries +- Services requiring proprietary API keys with no open alternative + +### 3. Evaluate the Full Picture +Beyond just the license, consider: +- **Governance**: Is the project community-governed or single-company controlled? +- **Self-hostability**: Can it be fully self-hosted without phoning home? +- **Data sovereignty**: Does data stay on your infrastructure? +- **Vendor lock-in risk**: How hard is it to migrate away? +- **Open-core concerns**: Is the open source version meaningfully usable, or is it crippled to upsell? +- **Transitive dependencies**: Do key dependencies have problematic licenses? + +### 4. Provide Clear Recommendations + +For each item reviewed, provide: +- **Status**: ✅ Approved, ⚠️ Caution, ❌ Not Recommended +- **License**: The specific license +- **Reasoning**: Why it passes or fails +- **Alternative** (if not recommended): A FOSS alternative that achieves the same goal + +## Project Context + +The Changemaker Lite project already uses these FOSS-aligned technologies (use as reference for what's acceptable): + +| Component | License | Category | +|-----------|---------|----------| +| PostgreSQL | PostgreSQL License | Database | +| Redis | BSD-3-Clause (pre-7.4) / RSALv2+SSPLv1 (7.4+) | Cache/Queue | +| Nginx | BSD-2-Clause | Reverse Proxy | +| Node.js/Express | MIT | API Framework | +| Fastify | MIT | API Framework | +| React | MIT | Frontend | +| Vite | MIT | Build Tool | +| Ant Design | MIT | UI Library | +| Prisma | Apache-2.0 | ORM | +| BullMQ | MIT | Job Queue | +| Prometheus | Apache-2.0 | Monitoring | +| Grafana | AGPL-3.0 | Dashboards | +| Listmonk | AGPL-3.0 | Newsletter | +| NocoDB | AGPL-3.0 | Data Browser | +| Gitea | MIT | Git Hosting | +| n8n | Sustainable Use License (⚠️) | Workflow | +| MkDocs | BSD-2-Clause | Documentation | +| GrapesJS | BSD-3-Clause | Page Builder | +| Leaflet | BSD-2-Clause | Maps | +| Docker | Apache-2.0 | Containers | + +**Note on Redis**: Redis changed to dual RSALv2+SSPLv1 in v7.4. The project may be using an older BSD-licensed version or a fork like Valkey (BSD-3-Clause). Flag this if relevant. + +**Note on n8n**: n8n uses the Sustainable Use License which is NOT OSI-approved. It's already in the project but should be noted as an exception. + +## Output Format + +Structure your review as follows: + +``` +## FOSS Compliance Review + +### Items Reviewed +| Item | License | Status | Notes | +|------|---------|--------|-------| +| ... | ... | ✅/⚠️/❌ | ... | + +### Detailed Findings +[For each item that is ⚠️ or ❌, provide detailed analysis] + +### FOSS Alternatives +[For each ❌ item, suggest FOSS replacements] + +### Overall Assessment +[Summary: Is the project maintaining its FOSS commitment?] +``` + +## Important Guidelines + +1. **Be pragmatic, not dogmatic.** A project that is 95% FOSS with a few pragmatic exceptions (like n8n) is still a strong FOSS project. Note exceptions but don't treat them as failures. + +2. **Distinguish between dependencies and services.** An MIT-licensed npm package that only works with a proprietary API is effectively proprietary. Evaluate the full dependency chain. + +3. **Consider the ecosystem.** Some packages are so standard (e.g., Express, React) that their FOSS status is well-established. Focus your detailed analysis on less common or newer additions. + +4. **Check actual files when possible.** Use tools to read `package.json` files, `docker-compose.yml`, and other configuration to identify what's actually in use. Don't rely solely on what the user tells you. + +5. **Flag copyleft implications.** If a GPL/AGPL dependency is being used, note any distribution or linking implications, especially for the API server. + +6. **Acknowledge trade-offs.** Sometimes there's no good FOSS alternative for a specific need. In those cases, be honest about the trade-off rather than recommending an inferior FOSS option. + +7. **When reviewing recently added code**, focus on new `import` statements, new entries in `package.json`, new services in `docker-compose.yml`, and any new external API integrations. + +**Update your agent memory** as you discover licensing information about dependencies, services with licensing changes (like Redis's license change), FOSS alternatives that work well for specific use cases, and any exceptions or trade-offs the project has accepted. This builds institutional knowledge across conversations. Write concise notes about what you found. + +Examples of what to record: +- License classifications for commonly used packages +- Known licensing changes in popular projects (e.g., Redis, Elasticsearch, Terraform) +- Verified FOSS alternatives that have been evaluated +- Project-specific exceptions and the reasoning behind them +- Transitive dependency issues discovered during reviews + +# Persistent Agent Memory + +You have a persistent Persistent Agent Memory directory at `/home/bunker-admin/changemaker.lite/.claude/agent-memory/foss-compliance-reviewer/`. Its contents persist across conversations. + +As you work, consult your memory files to build on previous experience. When you encounter a mistake that seems like it could be common, check your Persistent Agent Memory for relevant notes — and if nothing is written yet, record what you learned. + +Guidelines: +- `MEMORY.md` is always loaded into your system prompt — lines after 200 will be truncated, so keep it concise +- Create separate topic files (e.g., `debugging.md`, `patterns.md`) for detailed notes and link to them from MEMORY.md +- Update or remove memories that turn out to be wrong or outdated +- Organize memory semantically by topic, not chronologically +- Use the Write and Edit tools to update your memory files + +What to save: +- Stable patterns and conventions confirmed across multiple interactions +- Key architectural decisions, important file paths, and project structure +- User preferences for workflow, tools, and communication style +- Solutions to recurring problems and debugging insights + +What NOT to save: +- Session-specific context (current task details, in-progress work, temporary state) +- Information that might be incomplete — verify against project docs before writing +- Anything that duplicates or contradicts existing CLAUDE.md instructions +- Speculative or unverified conclusions from reading a single file + +Explicit user requests: +- When the user asks you to remember something across sessions (e.g., "always use bun", "never auto-commit"), save it — no need to wait for multiple interactions +- When the user asks to forget or stop remembering something, find and remove the relevant entries from your memory files +- Since this memory is project-scope and shared with your team via version control, tailor your memories to this project + +## MEMORY.md + +Your MEMORY.md is currently empty. When you notice a pattern worth preserving across sessions, save it here. Anything in MEMORY.md will be included in your system prompt next time. diff --git a/CLAUDE.md b/CLAUDE.md index 32254cee..c43d3219 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,212 +6,734 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Changemaker Lite is a self-hosted political campaign platform built with Docker Compose. It consolidates advocacy email campaigns, geographic mapping, volunteer management, and administration into a single TypeScript stack. The primary domain is `cmlite.org`. -**Current state:** V2 rebuild in progress on the `v2` branch. See `V2_PLAN.md` for the full roadmap. +**Current state:** V2 rebuild substantially complete on the `v2` branch. Core platform operational with Phases 1-14 complete. See `V2_PLAN.md` for the full roadmap. + +**Status Summary:** +- ✅ Phases 1-14 Complete (Foundation through Monitoring + DevOps) +- ✅ Security Audit Complete (13 findings addressed, Feb 2026) +- ✅ NAR 2025 Server Import (Canadian electoral data) +- ✅ Media Manager Integration (dual API architecture) +- ✅ Email Templates System +- ✅ Data Quality Dashboard +- ✅ Observability Dashboard +- ✅ **Drizzle to Prisma Migration Complete** (Media API consolidated to single-ORM, Feb 2026) +- ✅ **Automated Pangolin Setup** (One-command tunnel deployment, Feb 2026) +- 🚧 Phase 15 (Testing + Polish) - Next --- -## V2 Architecture (Active Development) +## V2 Architecture ### Stack -- **Single unified Express.js API** — TypeScript, port 4000, Prisma ORM + PostgreSQL 16 +- **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 -- **JWT auth** — access tokens (15min) + refresh tokens (7 days, stored in DB) -- **BullMQ** — async email job queue, **Listmonk** for newsletters -- **Redis** — caching, rate limiting, BullMQ backend +- **Redis** — caching, rate limiting, BullMQ backend, geocoding queue (authenticated) +- **Monitoring Stack** (Docker profile: `monitoring`) — Prometheus, Grafana, Alertmanager, cAdvisor, exporters -### Directory Structure +### 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`, `USER`, `TEMP` +- **RBAC:** `requireRole(...roles)`, `requireNonTemp`, `authenticate` middleware +- **Security features:** + - Refresh token rotation (atomic transaction) + - User enumeration prevention (401 not 404) + - Rate limiting on auth endpoints (10/min) + - Redis authentication required + - XSS/injection prevention (HTML escaping) + - Path traversal protection + - Encryption key for DB secrets (ENCRYPTION_KEY env var) + - Security audit complete (13 findings addressed, see `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/ # Unified Express.js API (TypeScript) -│ ├── prisma/ # Schema, migrations, seed +├── api/ # Dual API servers (Express + Fastify) +│ ├── prisma/ +│ │ ├── schema.prisma # 30+ models: User, Campaign, Location, Shift, etc. +│ │ ├── migrations/ # Prisma migration history +│ │ └── seed.ts # Admin user, settings, page blocks +│ ├── drizzle/ # Media tables (Drizzle ORM) +│ ├── Dockerfile.media # Fastify media server container │ └── src/ -│ ├── config/ # env.ts, database.ts, redis.ts -│ ├── middleware/ # error-handler, validate, rate-limit, auth, rbac +│ ├── 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/ -│ │ ├── 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 (Request augmentation) -│ └── utils/ # logger.ts (Winston), metrics.ts (prom-client) -├── admin/ # React Admin (Vite + Ant Design + Zustand) +│ │ ├── auth/ # JWT login, register, refresh, logout +│ │ ├── users/ # User CRUD + pagination + search +│ │ ├── settings/ # Site settings singleton +│ │ ├── 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/ +│ │ │ ├── pages-admin.routes.ts # Landing page CRUD +│ │ │ ├── pages-public.routes.ts # Public page renderer +│ │ │ └── blocks.routes.ts # Block library API +│ │ ├── email-templates/ # Email template CRUD + rendering +│ │ ├── media/ # Fastify media API (videos, reactions, jobs) +│ │ ├── listmonk/ # Newsletter sync admin routes +│ │ ├── pangolin/ # Tunnel management (Newt integration) +│ │ ├── docs/ # MkDocs + Code Server health checks +│ │ ├── qr/ # QR code PNG generation (public) +│ │ └── 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/ -│ ├── components/ # ProtectedRoute, AppLayout -│ ├── pages/ # LoginPage, DashboardPage, UsersPage -│ ├── stores/ # auth.store.ts (Zustand) -│ ├── lib/ # api.ts (axios instance + interceptors) -│ └── types/ # api.ts (TypeScript interfaces) -├── nginx/ # Reverse proxy config -├── public-web/ # Public landing pages -├── docker-compose.yml # V2 orchestration -├── docker-compose.v1.yml # V1 backup for reference -└── V2_PLAN.md # Full 14-phase roadmap +│ ├── 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/ +│ │ ├── auth/ # LoginPage +│ │ ├── DashboardPage.tsx # Admin dashboard +│ │ ├── UsersPage.tsx # User CRUD +│ │ ├── SettingsPage.tsx # Global site settings +│ │ ├── influence/ +│ │ │ ├── CampaignsPage.tsx # Campaign management +│ │ │ ├── ResponsesPage.tsx # Response moderation +│ │ │ ├── RepresentativesPage.tsx # Rep cache admin +│ │ │ └── EmailQueuePage.tsx # Queue monitoring +│ │ ├── map/ +│ │ │ ├── LocationsPage.tsx # Location CRUD + CSV + geocoding +│ │ │ ├── CutsPage.tsx # Cut table + map drawing editor +│ │ │ ├── ShiftsPage.tsx # Shift CRUD + signups drawer +│ │ │ ├── MapSettingsPage.tsx # Map settings +│ │ │ └── DataQualityDashboardPage.tsx # Geocoding quality metrics +│ │ ├── CanvassDashboardPage.tsx # Admin canvass overview +│ │ ├── WalkSheetPage.tsx # Printable walk sheet +│ │ ├── CutExportPage.tsx # Printable location report +│ │ ├── volunteer/ +│ │ │ ├── VolunteerMapPage.tsx # Full-screen GPS canvass map +│ │ │ ├── VolunteerShiftsPage.tsx # Assigned shifts +│ │ │ ├── MyActivityPage.tsx # Visit history + outcomes +│ │ │ └── MyRoutesPage.tsx # Route history +│ │ ├── public/ +│ │ │ ├── CampaignsListPage.tsx # Public campaign listing +│ │ │ ├── CampaignPage.tsx # Campaign detail + email form +│ │ │ ├── ResponseWallPage.tsx # Public response wall +│ │ │ ├── MapPage.tsx # Public Leaflet map +│ │ │ ├── ShiftsPage.tsx # Public shift signup +│ │ │ ├── LandingPage.tsx # Rendered landing page (/p/:slug) +│ │ │ ├── MediaGalleryPage.tsx # Public video gallery +│ │ │ └── MediaViewerPage.tsx # Video detail page +│ │ ├── media/ +│ │ │ ├── LibraryPage.tsx # Video library management +│ │ │ ├── SharedMediaPage.tsx # Public gallery admin +│ │ │ └── MediaJobsPage.tsx # Job queue monitoring +│ │ ├── LandingPagesPage.tsx # Landing page manager +│ │ ├── PageEditorPage.tsx # Full-screen GrapesJS editor +│ │ ├── EmailTemplatesPage.tsx # Email template CRUD +│ │ ├── EmailTemplateEditorPage.tsx # Email template editor +│ │ ├── ListmonkPage.tsx # Newsletter sync management +│ │ ├── PangolinPage.tsx # Tunnel setup wizard +│ │ ├── DocsPage.tsx # MkDocs export management +│ │ ├── MkDocsSettingsPage.tsx # Documentation config +│ │ ├── ObservabilityPage.tsx # Monitoring dashboard +│ │ └── services/ +│ │ ├── MiniQRPage.tsx # Mini QR iframe +│ │ ├── MailHogPage.tsx # Email capture UI +│ │ ├── CodeEditorPage.tsx # Code Server management +│ │ ├── N8nPage.tsx # Workflow automation +│ │ ├── GiteaPage.tsx # Git repository hosting +│ │ └── NocoDBPage.tsx # Data browser management +│ ├── stores/ # auth.store.ts, canvass.store.ts (Zustand) +│ ├── lib/ # api.ts, media-api.ts, media-public-api.ts (axios) +│ ├── hooks/ # useDebounce, useLocalStorage +│ └── types/ # api.ts, canvass.ts, media.ts (TypeScript interfaces) +│ +├── media-manager/ # Legacy media manager (reference) +├── nginx/ # Reverse proxy config (subdomain routing + CSP) +├── configs/ # Prometheus, Grafana, Alertmanager configs +├── scripts/ # backup.sh, legacy Cloudflare scripts +├── docker-compose.yml # V2 orchestration (20+ services) +├── docker-compose.v1.yml # V1 backup (reference) +├── .env.example # All required environment variables +└── V2_PLAN.md # Full 14-phase roadmap ``` -### Key Files - -| File | Purpose | -|------|---------| -| `api/prisma/schema.prisma` | Full database schema (20+ models) | -| `api/src/server.ts` | API entry point, middleware stack, route wiring | -| `api/src/config/env.ts` | Zod-validated environment config | -| `api/src/modules/auth/` | JWT auth (login, register, refresh, logout) | -| `api/src/modules/users/` | User CRUD with pagination + search | -| `admin/src/App.tsx` | React admin shell with routing | -| `admin/src/stores/auth.store.ts` | Zustand auth state with token persistence | -| `admin/src/lib/api.ts` | Axios instance with 401 refresh interceptor | -| `docker-compose.yml` | V2 service orchestration | -| `.env.example` | All required environment variables | - -### Auth Flow - -- JWT-based: access tokens (15min) + 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` -- RBAC middleware: `requireRole(...roles)`, `requireNonTemp` - -### Nginx Routing - -| Subdomain | Target | -|-----------|--------| -| `app.cmlite.org` | Admin React app (port 3000) | -| `api.cmlite.org` | Express API (port 4000) | -| `data.cmlite.org` | NocoDB read-only (port 8091) | -| `docs.cmlite.org` | MkDocs (port 4001) | -| `cmlite.org` | Public landing pages | - --- -## V2 Development Commands +## Quick Start Guide + +### Initial Setup (First Time) + +1. **Clone repository and checkout v2 branch:** + ```bash + git clone changemaker.lite + cd changemaker.lite + git checkout v2 + ``` + +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 | - | +| NocoDB | http://localhost:8091 | See `NC_ADMIN_EMAIL`/`NC_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 | + +### Feature Flags + +Enable optional features in `.env`: + +```bash +# Media Manager +ENABLE_MEDIA_FEATURES=true + +# Listmonk Newsletter Sync +LISTMONK_SYNC_ENABLED=true + +# Email Test Mode (sends to MailHog instead of SMTP) +EMAIL_TEST_MODE=true +``` + +--- + +## Development Commands + +The user likes to use Docker - recereating services as if in production. ### API Development ```bash -cd api && npm run dev # Dev server with tsx watch (auto-reload) -cd api && npx tsc --noEmit # Type-check without emitting -cd api && npx prisma migrate dev # Run/create migrations -cd api && npx prisma studio # Browse database in browser -cd api && npx prisma generate # Regenerate Prisma client +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 +cd api && npx drizzle-kit push # Push Drizzle schema changes (media) ``` -### Admin GUI Development +### Admin Development ```bash cd admin && npm run dev # Vite dev server (port 3000) -cd admin && npx tsc --noEmit # Type-check without emitting -cd admin && npm run build # Production build (tsc + vite) +cd admin && npx tsc --noEmit # Type-check +cd admin && npm run build # Production build ``` -### Docker (V2 Services) +### Docker Operations ```bash -docker compose up -d v2-postgres redis api # Start API + dependencies -docker compose up -d admin # Start admin GUI -docker compose up -d # Start all v2 services -docker compose logs -f api # Tail API logs -docker compose exec api npx prisma migrate dev # Run migrations in container -docker compose down # Stop all services +# 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 +docker compose exec api npx drizzle-kit push + +# Stop services +docker compose down ``` -### Type Checking (Both Projects) +### 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 ``` --- -## Port Reference (V2) +## Core Modules Reference -| Port | Service | -|------|---------| -| 3000 | Admin GUI (Vite dev / React) | -| 3001 | Grafana | -| 3010 | Homepage | -| 3030 | Gitea | -| 4000 | V2 API (Express.js) | -| 4001 | MkDocs (built static) | -| 5432 | Listmonk PostgreSQL | -| 5433 | V2 PostgreSQL (localhost) | -| 5678 | n8n | -| 6379 | Redis | -| 8025 | MailHog Web UI | -| 8080 | cAdvisor | -| 8089 | Mini QR | -| 8091 | NocoDB v2 (read-only) | -| 8888 | Code Server | -| 9001 | Listmonk | -| 9090 | Prometheus | -| 9093 | Alertmanager | +### 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, video analytics service +- `api/src/modules/media/routes/` — Video CRUD, actions, schedule, analytics, tracking, upload +- `api/src/services/video-schedule-queue.service.ts` — BullMQ queue for scheduled publishing +- `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:** Upload with FFprobe metadata extraction (duration, dimensions, orientation, quality), bulk operations +- **Quick Actions** (Feb 2026): Edit, preview, analytics, schedule, duplicate, preview links (24h JWT), reset analytics +- **Scheduled Publishing** (Feb 2026): BullMQ job queue, timezone support (11 zones), calendar view, publish/unpublish automation +- **Analytics** (Feb 2026): Views, watch time, completion rate, traffic sources, registered viewers, GDPR-compliant (IP hashing, 90-day retention) +- **Tracking:** Public endpoints for view/event recording, 10s heartbeat, navigator.sendBeacon for reliability +- **UI Features:** Keyboard shortcuts (E/P/A/S), hover overlays, skeleton loading, error handling, mobile responsive + +**Routes:** +- Admin: `/app/media/library`, `/app/media/analytics`, `/app/media/shared`, `/app/media/jobs` +- Public: `/gallery` (public video gallery), `/gallery/watch/:id` (video viewer), `/media/:id` (backwards compatible viewer route) +- Tracking (public): `/track/view`, `/track/event`, `/track/heartbeat` + +**Note:** The public gallery is served at `/gallery` via the admin app using `MediaPublicLayout`. This provides a unified purple interface for both authenticated and unauthenticated users. The gallery supports optional authentication (session-based upvoting/commenting for anonymous users). + +**Documentation:** +- [Media Admin Features Guide](./docs/MEDIA_ADMIN_FEATURES.md) — Complete feature documentation +- [Video Analytics Guide](./docs/VIDEO_ANALYTICS_GUIDE.md) — Analytics setup and interpretation +- [Media API README](./api/src/modules/media/README.md) — Architecture overview + +### 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/pangolin-setup.sh` — CLI wrapper for automated setup +- `configs/pangolin/resources.yml` — Central resource definitions (12 services) +- Newt container integration (Cloudflare alternative) +- **Automated setup:** One-command deployment (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 (Drizzle) | +| 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 | +| 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 | +| 8091 | NocoDB | Data browser | +| 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 | +| `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 | +| `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:** See `MEMORY.md` for comprehensive development patterns, gotchas, and lessons learned. Below are V2-specific patterns only. + +### 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/` (AppLayout) +- Public pages: `admin/src/pages/public/` (PublicLayout, dark theme) +- Volunteer pages: `admin/src/pages/volunteer/` (VolunteerLayout) +- Zustand stores: `auth.store.ts`, `canvass.store.ts` +- API clients: `{ api }` from `lib/api.ts`, `mediaApi` from `lib/media-api.ts` + +### Database ORMs +- **Prisma** (main API): Use `UncheckedCreateInput`/`UncheckedUpdateInput` for foreign keys, `Prisma.InputJsonValue` for JSON arrays +- **Drizzle** (media API): Separate schema file, push with `npx drizzle-kit push`, no migrations generated + +### V2-Specific Gotchas +- Fastify media API on port 4100, separate from Express on 4000 (same DB, different ORM) +- Volunteer page naming: `VolunteerShiftsPage.tsx` (not "MyAssignmentsPage") +- Tracking module: `api/src/modules/map/tracking/` (volunteer + admin routes) +- Pages module: 3 route files (pages-admin, pages-public, blocks) +- Vite proxy: `VITE_API_URL`, `VITE_MKDOCS_URL` env vars (Docker sets to container hostnames) +- Nginx media API block must come BEFORE general API block +- MkDocs port 4003 (was 4000, conflicted with API) +- Media upload: requires separate RW volume mount for inbox directory (`:rw` on `/media/local/inbox`), library remains read-only +- FFmpeg/FFprobe: installed in media-api container (Alpine `apk add --no-cache ffmpeg`), used for metadata extraction + +--- + +## Security & Configuration + +### Security Audit +Comprehensive security audit completed 2025-02-11, addressing 13 findings. See `SECURITY_AUDIT_2025-02-11.md` for full report. + +**Key improvements:** +- Password policy: 12+ chars, uppercase, lowercase, digit (schema-enforced) +- Rate limits on auth endpoints (10/min per IP) +- Refresh token rotation (atomic transaction) +- User enumeration prevention (401 not 404) +- Redis authentication required +- XSS/injection prevention (HTML escaping) +- Path traversal protection +- Encryption key for DB secrets (`ENCRYPTION_KEY` required in production) +- Nginx security headers (HSTS, Permissions-Policy, CSP) + +### 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 betteredmonton.org +CORS_ORIGINS=http://app.betteredmonton.org,https://app.betteredmonton.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.betteredmonton.org` - Main API (all endpoints fail without this) +- `app.betteredmonton.org` - Admin GUI + public pages +- `media.betteredmonton.org` - Media API + +**Verification:** +```bash +# Should return JSON, NOT a 302 redirect +curl https://api.betteredmonton.org/api/health +``` + +**See Also:** `PRODUCTION_403_FIX.md` for detailed step-by-step instructions. + +### 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 Connection Failures + +**Symptom:** API logs show database connection errors. + +**Fix:** +1. Check PostgreSQL container: `docker compose ps v2-postgres` +2. Verify `DATABASE_URL` in `.env` matches container name and port +3. Check PostgreSQL logs: `docker compose logs v2-postgres --tail 50` +4. Test connection: `docker compose exec api npx prisma db execute --stdin <<< "SELECT 1"` + +### Redis Connection Failures + +**Symptom:** API logs show Redis connection errors, rate limiting doesn't work. + +**Fix:** +1. Check Redis container: `docker compose ps redis-changemaker` +2. Verify `REDIS_PASSWORD` matches in `.env` and `REDIS_URL` format +3. Check Redis logs: `docker compose logs redis-changemaker --tail 50` +4. Test connection: `docker compose exec redis-changemaker redis-cli -a $REDIS_PASSWORD ping` --- ## V1 Reference (Legacy) -V1 code is preserved in `influence/` and `map/` directories and backed up in `docker-compose.v1.yml`. - -### V1 Architecture - -Two independent Express.js apps using NocoDB REST API as data layer: - -- **Influence** (`influence/app/`, port 3333) — Postal code → representative lookup, email campaigns, response tracking -- **Map** (`map/app/`, port 3000) — Leaflet.js map, volunteer shifts, walk sheets, QR codes - -Both apps use: session-based auth (Redis-backed), bcryptjs passwords, Bull job queues, NocoDB REST API (not direct DB). - -### V1 Express App Structure -``` -app/ -├── server.js # Entry point, middleware stack -├── config/ # Environment-based configuration -├── routes/ # Express route definitions -├── controllers/ # Business logic -├── services/ # External integrations (nocodb.js, email.js, listmonk.js) -├── middleware/ # auth.js, csrf.js, rateLimiter.js -├── utils/ # logger.js, metrics.js, validators.js -├── public/ # Static assets -└── templates/ # Server-rendered HTML templates -``` - -### V1 Commands -```bash -cd influence && cp example.env .env -./scripts/build-nocodb.sh # Initialize NocoDB tables -docker compose up -d -docker compose exec influence-app npm test # Run Jest tests - -cd map && cp example.env .env -./build-nocodb.sh # Initialize NocoDB tables -docker compose up -d -``` - -### V1 Build Scripts -- `config.sh` — Interactive wizard that generates `.env` with secure random passwords -- `start-production.sh` — Installs cloudflared, creates tunnel, configures DNS -- `map/build-nocodb.sh` and `influence/scripts/build-nocodb.sh` — Create NocoDB schema + seed data -- `reset-site.sh` — Resets MkDocs to baseline - -### V1 Documentation -- `influence/README.MD` — Features, config, campaign management, email testing -- `influence/files-explainer.md` — File-by-file code documentation +V1 code archived in `influence/`, `map/`, and `docker-compose.v1.yml`. Two independent Express apps using NocoDB REST API. See individual README files for V1 documentation: +- `influence/README.MD` — Features, config, campaign management - `map/README.md` — Features, config, setup instructions -- `map/files-explainer.md` — File-by-file code documentation +- Both use session-based auth, bcryptjs passwords, Bull job queues --- ## Key Configuration Files -| File | Purpose | -|------|---------| -| `docker-compose.yml` | V2 orchestration (all services) | -| `docker-compose.v1.yml` | V1 backup | -| `.env` / `.env.example` | Environment variables (never committed) | -| `api/prisma/schema.prisma` | Database schema | -| `nginx/` | Reverse proxy configuration | -| `configs/prometheus/prometheus.yml` | Monitoring scrape targets | -| `configs/cloudflare/tunnel-config.yml` | Production ingress routing | +### Infrastructure +- `docker-compose.yml` — V2 orchestration (20+ services, monitoring profile) +- `.env` / `.env.example` — Environment variables (100+ vars) -## Networking +### Database +- `api/prisma/schema.prisma` — Main schema (30+ Prisma models) +- `api/drizzle.config.ts` — Drizzle config for media tables +- `api/prisma/seed.ts` — Database seeding -All containers share the `changemaker-lite` bridge network and reference each other by container name. Production uses Cloudflare tunnel with ingress rules mapping `*.cmlite.org` subdomains. +### 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` — Security audit report +- `MEMORY.md` — Development patterns and gotchas diff --git a/NARguide.pdf b/NARguide.pdf new file mode 100644 index 00000000..f96c12f2 Binary files /dev/null and b/NARguide.pdf differ diff --git a/PANGOLIN_NGINX_FIX_SUMMARY.md b/PANGOLIN_NGINX_FIX_SUMMARY.md new file mode 100644 index 00000000..eedff133 --- /dev/null +++ b/PANGOLIN_NGINX_FIX_SUMMARY.md @@ -0,0 +1,181 @@ +# 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) diff --git a/PRODUCTION_403_FIX.md b/PRODUCTION_403_FIX.md new file mode 100644 index 00000000..720cfc81 --- /dev/null +++ b/PRODUCTION_403_FIX.md @@ -0,0 +1,200 @@ +# 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` diff --git a/RNAguide.pdf b/RNAguide.pdf new file mode 100644 index 00000000..5bc01932 Binary files /dev/null and b/RNAguide.pdf differ diff --git a/V2_PLAN.md b/V2_PLAN.md index 27998296..a561f9b6 100644 --- a/V2_PLAN.md +++ b/V2_PLAN.md @@ -364,13 +364,22 @@ changemaker.lite/ --- -### Phase 15: Testing + Polish [ ] +### 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 -- [ ] Documentation updates +- [ ] Security audit (auth-security-reviewer for media features) +- [ ] UI design review (ui-design-critic for media components) ### PHASE 1: Extras - [ ] Add apache answers diff --git a/admin/package-lock.json b/admin/package-lock.json index 90eb7913..4e7f0d06 100644 --- a/admin/package-lock.json +++ b/admin/package-lock.json @@ -11,9 +11,11 @@ "@ant-design/icons": "^5.6.0", "@ant-design/v5-patch-for-react-19": "^1.0.3", "@monaco-editor/react": "^4.7.0", + "@types/dompurify": "^3.2.0", "antd": "^5.23.0", "axios": "^1.7.9", "dayjs": "^1.11.19", + "dompurify": "^3.3.1", "grapesjs": "^0.22.14", "grapesjs-blocks-basic": "^1.0.2", "grapesjs-component-countdown": "^1.0.2", @@ -26,11 +28,14 @@ "grapesjs-tabs": "^1.0.6", "grapesjs-touch": "^0.1.1", "grapesjs-typed": "^2.0.1", + "jwt-decode": "^4.0.0", "leaflet": "^1.9.4", "react": "^19.0.0", "react-dom": "^19.0.0", "react-leaflet": "^5.0.0", + "react-leaflet-cluster": "^4.0.0", "react-router-dom": "^7.1.1", + "recharts": "^3.7.0", "yaml": "^2.8.2", "zustand": "^5.0.3" }, @@ -901,7 +906,6 @@ "version": "4.7.0", "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz", "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==", - "license": "MIT", "dependencies": { "@monaco-editor/loader": "^1.5.0" }, @@ -1059,6 +1063,40 @@ "react-dom": "^19.0.0" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -1390,6 +1428,16 @@ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1440,6 +1488,69 @@ "@types/underscore": "*" } }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" + }, + "node_modules/@types/dompurify": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.2.0.tgz", + "integrity": "sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==", + "deprecated": "This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed.", + "dependencies": { + "dompurify": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -1502,14 +1613,18 @@ "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "optional": true, - "peer": true + "optional": true }, "node_modules/@types/underscore": { "version": "1.13.0", "resolved": "https://registry.npmjs.org/@types/underscore/-/underscore-1.13.0.tgz", "integrity": "sha512-L6LBgy1f0EFQZ+7uSA57+n2g/s4Qs5r06Vwrwn0/nuK1de+adz00NWaztRQ30aEqw5qOaWbPI8u2cGQ52lj6VA==" }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==" + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -1706,6 +1821,14 @@ "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==" }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "engines": { + "node": ">=6" + } + }, "node_modules/codemirror": { "version": "5.63.0", "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.63.0.tgz", @@ -1763,6 +1886,116 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "engines": { + "node": ">=12" + } + }, "node_modules/dayjs": { "version": "1.11.19", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", @@ -1785,6 +2018,11 @@ } } }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==" + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -1794,10 +2032,9 @@ } }, "node_modules/dompurify": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", - "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", - "peer": true, + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz", + "integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==", "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -1862,6 +2099,11 @@ "node": ">= 0.4" } }, + "node_modules/es-toolkit": { + "version": "1.44.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz", + "integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==" + }, "node_modules/esbuild": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", @@ -1912,6 +2154,11 @@ "node": ">=6" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2162,6 +2409,23 @@ "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-1.4.0.tgz", "integrity": "sha512-8nxjcBcd8wovbeKx7h3wTji4e6+rhaVuPNpMqwWgnHh+N9ToqsCs6XztWRBPQ+UtzsoMAdKZtUENoVzU/EMtZA==" }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "engines": { + "node": ">=12" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2200,12 +2464,29 @@ "node": ">=6" } }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/leaflet": { "version": "1.9.4", "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", "license": "BSD-2-Clause" }, + "node_modules/leaflet.markercluster": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz", + "integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==", + "peerDependencies": { + "leaflet": "^1.3.1" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -2264,6 +2545,15 @@ "marked": "14.0.0" } }, + "node_modules/monaco-editor/node_modules/dompurify": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", + "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "peer": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2960,6 +3250,43 @@ "react-dom": "^19.0.0" } }, + "node_modules/react-leaflet-cluster": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/react-leaflet-cluster/-/react-leaflet-cluster-4.0.0.tgz", + "integrity": "sha512-Lu75+KOu2ruGyAx8LoCQvlHuw+3CLLJQGEoSk01ymsDN/YnCiRV6ChkpsvaruVyYBPzUHwiskFw4Jo7WHj5qNw==", + "dependencies": { + "leaflet.markercluster": "^1.5.3" + }, + "peerDependencies": { + "@react-leaflet/core": "^3.0.0", + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-leaflet": "^5.0.0" + } + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -3005,6 +3332,50 @@ "react-dom": ">=18" } }, + "node_modules/recharts": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz", + "integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==", + "dependencies": { + "@reduxjs/toolkit": "1.x.x || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==" + }, "node_modules/resize-observer-polyfill": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", @@ -3113,6 +3484,11 @@ "node": ">=12.22" } }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -3182,6 +3558,35 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, "node_modules/vite": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", diff --git a/admin/package.json b/admin/package.json index 2d410932..8e9ae309 100644 --- a/admin/package.json +++ b/admin/package.json @@ -12,9 +12,11 @@ "@ant-design/icons": "^5.6.0", "@ant-design/v5-patch-for-react-19": "^1.0.3", "@monaco-editor/react": "^4.7.0", + "@types/dompurify": "^3.2.0", "antd": "^5.23.0", "axios": "^1.7.9", "dayjs": "^1.11.19", + "dompurify": "^3.3.1", "grapesjs": "^0.22.14", "grapesjs-blocks-basic": "^1.0.2", "grapesjs-component-countdown": "^1.0.2", @@ -27,11 +29,14 @@ "grapesjs-tabs": "^1.0.6", "grapesjs-touch": "^0.1.1", "grapesjs-typed": "^2.0.1", + "jwt-decode": "^4.0.0", "leaflet": "^1.9.4", "react": "^19.0.0", "react-dom": "^19.0.0", "react-leaflet": "^5.0.0", + "react-leaflet-cluster": "^4.0.0", "react-router-dom": "^7.1.1", + "recharts": "^3.7.0", "yaml": "^2.8.2", "zustand": "^5.0.3" }, diff --git a/admin/src/App.tsx b/admin/src/App.tsx index fba4fe45..6267838f 100644 --- a/admin/src/App.tsx +++ b/admin/src/App.tsx @@ -8,14 +8,17 @@ import FeatureGate from '@/components/FeatureGate'; import AppLayout from '@/components/AppLayout'; import PublicLayout from '@/components/PublicLayout'; import VolunteerLayout from '@/components/VolunteerLayout'; +import MediaPublicLayout from '@/components/MediaPublicLayout'; import LoginPage from '@/pages/LoginPage'; import DashboardPage from '@/pages/DashboardPage'; import UsersPage from '@/pages/UsersPage'; import CampaignsPage from '@/pages/CampaignsPage'; import RepresentativesPage from '@/pages/RepresentativesPage'; import EmailQueuePage from '@/pages/EmailQueuePage'; +import EmailTemplatesPage from '@/pages/EmailTemplatesPage'; import ResponsesPage from '@/pages/ResponsesPage'; import LocationsPage from '@/pages/LocationsPage'; +import DataQualityDashboardPage from '@/pages/DataQualityDashboardPage'; import CutsPage from '@/pages/CutsPage'; import ShiftsPage from '@/pages/ShiftsPage'; import MapSettingsPage from '@/pages/MapSettingsPage'; @@ -23,7 +26,6 @@ import CutExportPage from '@/pages/CutExportPage'; import CanvassDashboardPage from '@/pages/CanvassDashboardPage'; import ListmonkPage from '@/pages/ListmonkPage'; import LandingPagesPage from '@/pages/LandingPagesPage'; -import PageEditorPage from '@/pages/PageEditorPage'; import DocsPage from '@/pages/DocsPage'; import MkDocsSettingsPage from '@/pages/MkDocsSettingsPage'; import CodeEditorPage from '@/pages/CodeEditorPage'; @@ -31,14 +33,22 @@ import NocoDBPage from '@/pages/NocoDBPage'; import N8nPage from '@/pages/N8nPage'; import GiteaPage from '@/pages/GiteaPage'; import MailHogPage from '@/pages/MailHogPage'; +import MiniQRPage from '@/pages/MiniQRPage'; +import ExcalidrawPage from '@/pages/ExcalidrawPage'; import SettingsPage from '@/pages/SettingsPage'; import PangolinPage from '@/pages/PangolinPage'; +import ObservabilityPage from '@/pages/ObservabilityPage'; +import LibraryPage from '@/pages/media/LibraryPage'; +import AnalyticsDashboardPage from '@/pages/media/AnalyticsDashboardPage'; +import MediaJobsPage from '@/pages/media/MediaJobsPage'; import PublicLandingPage from '@/pages/public/LandingPage'; import CampaignsListPage from '@/pages/public/CampaignsListPage'; import CampaignPage from '@/pages/public/CampaignPage'; import ResponseWallPage from '@/pages/public/ResponseWallPage'; import MapPage from '@/pages/public/MapPage'; import PublicShiftsPage from '@/pages/public/ShiftsPage'; +import MediaGalleryPage from '@/pages/public/MediaGalleryPage'; +import MediaViewerPage from '@/pages/public/MediaViewerPage'; import MyActivityPage from '@/pages/volunteer/MyActivityPage'; import VolunteerShiftsPage from '@/pages/volunteer/VolunteerShiftsPage'; import MyRoutesPage from '@/pages/volunteer/MyRoutesPage'; @@ -126,6 +136,15 @@ export default function App() { } /> } /> + {/* Public Media Gallery (purple theme) — feature-gated */} + }> + } /> + } /> + + } /> + {/* Email link alias for video viewer */} + } /> + {/* Volunteer map — full-screen, default landing page */} } /> - - - - } - /> } /> + + + + } + /> } /> + + + + } + /> + + + + } + /> } /> + + + + } + /> } /> + + + + } + /> } /> + + + + } + /> + + + + } + /> + + + + } + /> } /> diff --git a/admin/src/components/AppLayout.tsx b/admin/src/components/AppLayout.tsx index 2d2e4d65..e4ccb0ca 100644 --- a/admin/src/components/AppLayout.tsx +++ b/admin/src/components/AppLayout.tsx @@ -1,4 +1,4 @@ -import { useState, type ReactNode } from 'react'; +import { useState } from 'react'; import { useNavigate, useLocation, Outlet } from 'react-router-dom'; import { Layout, Menu, Dropdown, Button, Typography, Drawer, Grid, theme } from 'antd'; import { @@ -26,25 +26,27 @@ import { ApiOutlined, BranchesOutlined, CloudServerOutlined, + QrcodeOutlined, + VideoCameraOutlined, + FolderOutlined, + HistoryOutlined, + LineChartOutlined, + BarChartOutlined, + SoundOutlined, + EditOutlined, } from '@ant-design/icons'; import type { MenuProps } from 'antd'; import { useAuthStore } from '@/stores/auth.store'; import { useSettingsStore } from '@/stores/settings.store'; +import type { PageHeaderConfig, AppOutletContext } from '@/types/api'; + +// Re-export for backward compatibility +export type { PageHeaderConfig, AppOutletContext }; const { Header, Sider, Content } = Layout; const { Text } = Typography; const { useBreakpoint } = Grid; -export interface PageHeaderConfig { - title?: string; - actions?: ReactNode; - fullBleed?: boolean; -} - -export interface AppOutletContext { - setPageHeader: (config: PageHeaderConfig | null) => void; -} - function buildMenuItems(settings: import('@/types/api').SiteSettings | null): MenuProps['items'] { const items: MenuProps['items'] = [ { @@ -70,9 +72,13 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null): Me if (settings?.enableNewsletter !== false) { items.push({ - key: '/app/listmonk', + key: 'broadcast-submenu', icon: , - label: 'Newsletter', + label: 'Broadcast', + children: [ + { key: '/app/listmonk', icon: , label: 'Listmonk' }, + { key: '/app/email-templates', icon: , label: 'Email Templates' }, + ], }); } @@ -97,7 +103,8 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null): Me icon: , label: 'Map', children: [ - { key: '/app/map', label: 'Locations' }, + { key: '/app/map', icon: , label: 'Locations' }, + { key: '/app/map/data-quality', icon: , label: 'Data Quality' }, { key: '/app/map/shifts', icon: , label: 'Shifts' }, { key: '/app/map/cuts', icon: , label: 'Cuts' }, { key: '/app/map/canvass', icon: , label: 'Canvassing' }, @@ -106,6 +113,18 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null): Me }); } + if (settings?.enableMediaFeatures !== false) { + items.push({ + key: 'media-submenu', + icon: , + label: 'Media Library', + children: [ + { key: '/app/media/library', icon: , label: 'Videos' }, + { key: '/app/media/jobs', icon: , label: 'Processing Jobs' }, + ], + }); + } + items.push({ key: 'services-submenu', icon: , @@ -115,7 +134,10 @@ function buildMenuItems(settings: import('@/types/api').SiteSettings | null): Me { key: '/app/services/n8n', icon: , label: 'Workflows' }, { key: '/app/services/gitea', icon: , label: 'Git' }, { key: '/app/services/mailhog', icon: , label: 'MailHog' }, + { key: '/app/services/miniqr', icon: , label: 'QR Codes' }, + { key: '/app/services/excalidraw', icon: , label: 'Whiteboard' }, { key: '/app/tunnel', icon: , label: 'Tunnel' }, + { key: '/app/observability', icon: , label: 'Observability' }, ], }); @@ -230,7 +252,6 @@ export default function AppLayout() { theme="dark" mode="inline" selectedKeys={[selectedKey]} - defaultOpenKeys={['influence-submenu', 'map-submenu', 'web-submenu', 'services-submenu']} items={menuItems} onClick={handleMenuClick} /> @@ -304,6 +325,22 @@ export default function AppLayout() { > {!isMobile && 'Canvass'} + + `; + case 'video': { + const videoId = defaults.videoId || 'PLACEHOLDER'; + const playerType = defaults.playerType || 'standard'; + const width = defaults.width || '100%'; + const height = defaults.height || 'auto'; + + // Generate placeholder with data attributes for hydration + return ` +
+
+
+
+ + + +

Video Player

+

ID: ${videoId}

+

${playerType === 'advanced' ? 'Advanced Player (with reactions)' : 'Standard HTML5 Player'}

+

Video will render on published page

+
+
+
+
`; + } default: return `

Custom block: ${type}

`; } diff --git a/admin/src/components/MediaPublicLayout.tsx b/admin/src/components/MediaPublicLayout.tsx new file mode 100644 index 00000000..5e5bd52d --- /dev/null +++ b/admin/src/components/MediaPublicLayout.tsx @@ -0,0 +1,104 @@ +import { useEffect, useState } from 'react'; +import { ConfigProvider, Layout, theme, Grid } from 'antd'; +import { Outlet } from 'react-router-dom'; +import MediaSidebar from '@/components/media/MediaSidebar'; +import MediaBottomNav from '@/components/media/MediaBottomNav'; + +const { useBreakpoint } = Grid; + +export default function MediaPublicLayout() { + // Purple theme tokens matching media-manager aesthetic + const colorPrimary = '#9333ea'; // purple-600 + const colorBgBase = '#0d0d12'; // nearly black + const colorBgContainer = '#18181b'; // zinc-900 + const colorBgElevated = '#27272a'; // zinc-800 + + const screens = useBreakpoint(); + const isMobile = !screens.md; // < 768px + + // Get sidebar collapse state from localStorage + const [sidebarCollapsed, setSidebarCollapsed] = useState(() => { + const saved = localStorage.getItem('media_sidebar_collapsed'); + return saved ? JSON.parse(saved) : false; + }); + + // Listen for sidebar collapse state changes + useEffect(() => { + const handleStorage = () => { + const saved = localStorage.getItem('media_sidebar_collapsed'); + if (saved) { + setSidebarCollapsed(JSON.parse(saved)); + } + }; + + window.addEventListener('storage', handleStorage); + // Also poll localStorage every 100ms to catch same-window changes + const interval = setInterval(handleStorage, 100); + + return () => { + window.removeEventListener('storage', handleStorage); + clearInterval(interval); + }; + }, []); + + // Set document title for media pages + useEffect(() => { + document.title = 'Media Gallery | Changemaker Lite'; + }, []); + + // Calculate main content left margin based on sidebar state and screen size + const mainContentMarginLeft = isMobile ? 0 : sidebarCollapsed ? 64 : 256; + + return ( + + + {/* Desktop: Show sidebar, Mobile: Hide */} + {!isMobile && } + + {/* Main content area */} +
+
+ +
+
+ + {/* Mobile: Show bottom nav, Desktop: Hide */} + +
+
+ ); +} diff --git a/admin/src/components/PublicLayout.tsx b/admin/src/components/PublicLayout.tsx index 780c6535..1492ac8a 100644 --- a/admin/src/components/PublicLayout.tsx +++ b/admin/src/components/PublicLayout.tsx @@ -1,6 +1,7 @@ import { useEffect } from 'react'; -import { ConfigProvider, Layout, Typography, theme } from 'antd'; +import { ConfigProvider, Layout, Typography, theme, Space } from 'antd'; import { Outlet, Link } from 'react-router-dom'; +import { PlayCircleOutlined } from '@ant-design/icons'; import { useSettingsStore } from '@/stores/settings.store'; const { Header, Content, Footer } = Layout; @@ -53,12 +54,13 @@ export default function PublicLayout() { background: headerGradient, display: 'flex', alignItems: 'center', - justifyContent: 'center', + justifyContent: 'space-between', padding: '0 24px', height: 56, borderBottom: 'none', }} > + {/* Left: Logo */} {logoUrl && ( + + {/* Right: Navigation */} + + { + e.currentTarget.style.color = '#fff'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.color = 'rgba(255, 255, 255, 0.85)'; + }} + > + + Media Gallery + + {footerText}
- Return to Main Page + Campaigns + + {' • '} + + Media Gallery
diff --git a/admin/src/components/VolunteerFooterNav.tsx b/admin/src/components/VolunteerFooterNav.tsx index 58746f0d..6ec03912 100644 --- a/admin/src/components/VolunteerFooterNav.tsx +++ b/admin/src/components/VolunteerFooterNav.tsx @@ -5,6 +5,7 @@ import { CalendarOutlined, HistoryOutlined, NodeIndexOutlined, + MenuOutlined, } from '@ant-design/icons'; const NAV_ITEMS = [ @@ -16,9 +17,11 @@ const NAV_ITEMS = [ interface VolunteerFooterNavProps { style?: React.CSSProperties; + onMenuOpen?: () => void; + menuActive?: boolean; } -export default function VolunteerFooterNav({ style }: VolunteerFooterNavProps) { +export default function VolunteerFooterNav({ style, onMenuOpen, menuActive = false }: VolunteerFooterNavProps) { const navigate = useNavigate(); const location = useLocation(); const { token } = theme.useToken(); @@ -47,6 +50,29 @@ export default function VolunteerFooterNav({ style }: VolunteerFooterNavProps) { ...style, }} > + {/* Menu button */} + {onMenuOpen && ( +
+ + + Menu + +
+ )} + {NAV_ITEMS.map(({ key, icon: Icon, label }) => { const isActive = activeKey === key; return ( diff --git a/admin/src/components/canvass/AddLocationDrawer.tsx b/admin/src/components/canvass/AddLocationDrawer.tsx index 89025783..8954353a 100644 --- a/admin/src/components/canvass/AddLocationDrawer.tsx +++ b/admin/src/components/canvass/AddLocationDrawer.tsx @@ -15,6 +15,7 @@ interface AddLocationDrawerProps { userRole: UserRole; sessionId?: string; shiftId?: string; + zIndex?: number; } const outcomeKeys: VisitOutcome[] = [ @@ -35,12 +36,13 @@ export default function AddLocationDrawer({ lat, lng, userRole, - sessionId, - shiftId, + sessionId: _sessionId, + shiftId: _shiftId, + zIndex = 1000, }: AddLocationDrawerProps) { const [form] = Form.useForm(); const { message } = App.useApp(); - const { addLocation, recordVisit, reverseGeocode } = useCanvassStore(); + const { addLocation, reverseGeocode } = useCanvassStore(); const [loading, setLoading] = useState(false); const [geocoding, setGeocoding] = useState(false); const [outcome, setOutcome] = useState(null); @@ -99,24 +101,16 @@ export default function AddLocationDrawer({ if (showDetailFields && notes) locationData.notes = notes; // Create location - const newLoc = await addLocation(locationData); + await addLocation(locationData); // Track event point for location added useTrackingStore.getState().addEventPoint(lat, lng, 'LOCATION_ADDED'); - // Record visit on the new location - await recordVisit({ - locationId: newLoc.id, - outcome, - supportLevel, - signRequested, - signSize, - notes: notes || undefined, - sessionId, - shiftId, - }); + // TODO: Record visit on the new address + // Need to get addressId from created location (returned from addLocation above) + // For now, just add the location - visit can be recorded separately - message.success('Location added & visit recorded'); + message.success('Location added successfully'); onClose(); } catch { message.error('Failed to add location'); @@ -131,6 +125,7 @@ export default function AddLocationDrawer({ open={open} onClose={onClose} height="auto" + zIndex={zIndex} forceRender styles={{ body: { padding: '12px 16px', maxHeight: '70vh', overflow: 'auto' }, diff --git a/admin/src/components/canvass/AddressSearchOverlay.tsx b/admin/src/components/canvass/AddressSearchOverlay.tsx index 7729d765..49b10276 100644 --- a/admin/src/components/canvass/AddressSearchOverlay.tsx +++ b/admin/src/components/canvass/AddressSearchOverlay.tsx @@ -1,20 +1,23 @@ import { useState, useRef } from 'react'; -import { Input, Button, App } from 'antd'; +import { Input, Button, App, Grid } from 'antd'; import type { InputRef } from 'antd'; import { SearchOutlined, CloseOutlined } from '@ant-design/icons'; import { useCanvassStore } from '@/stores/canvass.store'; interface Props { onFlyTo: (lat: number, lng: number) => void; + style?: React.CSSProperties; } -export default function AddressSearchOverlay({ onFlyTo }: Props) { +export default function AddressSearchOverlay({ onFlyTo, style }: Props) { const [expanded, setExpanded] = useState(false); const [searching, setSearching] = useState(false); const [query, setQuery] = useState(''); const inputRef = useRef(null); const { message } = App.useApp(); const { geocodeSearch } = useCanvassStore(); + const screens = Grid.useBreakpoint(); + const isMobile = !screens.md; const handleSearch = async () => { if (!query.trim()) return; @@ -37,10 +40,7 @@ export default function AddressSearchOverlay({ onFlyTo }: Props) { onClick={() => setExpanded(true)} title="Search address" style={{ - position: 'absolute', - top: 12, - left: 60, - zIndex: 1000, + ...style, width: 40, height: 40, borderRadius: 8, @@ -63,10 +63,7 @@ export default function AddressSearchOverlay({ onFlyTo }: Props) { return (
setQuery(e.target.value)} onPressEnter={handleSearch} size="small" - style={{ width: 200, background: 'rgba(255,255,255,0.1)', border: 'none', color: '#fff' }} + style={{ + width: isMobile ? '100%' : 200, + maxWidth: isMobile ? '100%' : 200, + background: 'rgba(255,255,255,0.1)', + border: 'none', + color: '#fff', + }} autoFocus />
+ ); + } + + return ( + <> + {/* Single compact control bar */} +
+
+ {/* Left group: Collapse */} +
+ } + onClick={() => setCollapsed(true)} + label="Hide controls" + /> +
+ + {/* Center group: Session actions (if active) */} + {sessionActive && ( +
+ } + onClick={onNextDoor} + label="Next door" + type="primary" + /> + } + onClick={onToggleRoute} + label="Toggle route" + type={routeVisible ? 'primary' : 'default'} + ghost={routeVisible} + /> +
+ )} + + {/* Center group: Utility buttons */} +
+ } + onClick={() => setShowTiles(!showTiles)} + label="Map tiles" + type={showTiles ? 'primary' : 'default'} + /> + } + onClick={() => setShowSearch(!showSearch)} + label="Search" + type={showSearch ? 'primary' : 'default'} + /> + {cuts.length > 0 && ( + } + onClick={() => setShowCuts(!showCuts)} + label="Cuts" + type={showCuts ? 'primary' : 'default'} + badge={visibleCutIds.size > 0 ? visibleCutIds.size : undefined} + /> + )} + } + onClick={() => setShowLegend(!showLegend)} + label="Legend" + type={showLegend ? 'primary' : 'default'} + /> + {onAddAtCenter && ( + } + onClick={onAddAtCenter} + label="Add location" + /> + )} + {onToggleFullscreen && ( + : } + onClick={onToggleFullscreen} + label={fullscreen ? 'Exit fullscreen' : 'Fullscreen'} + type={fullscreen ? 'primary' : 'default'} + /> + )} +
+ + {/* Right group: GPS + Badge */} +
+ } + onClick={onToggleGps} + label="GPS" + type={gpsFollowing ? 'primary' : 'default'} + ghost={gpsFollowing} + /> + {sessionActive && ( + 0 ? '#27ae60' : '#3498db', + fontSize: 10, + height: 18, + lineHeight: '18px', + padding: '0 5px', + borderRadius: 9, + }} + /> + )} +
+
+
+ + {/* Floating panels */} + + {/* Tile layer popup - vertical stack above button */} + {showTiles && ( + <> + {/* Backdrop to close on tap */} +
setShowTiles(false)} + style={{ + position: 'fixed', + top: 0, + left: 0, + right: 0, + bottom: 0, + zIndex: 1089, + }} + /> + {/* Tile options - vertically stacked */} +
+ {TILE_LAYERS.map((layer) => ( + + ))} +
+ + )} + + {/* Search drawer */} + setShowSearch(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)' }, + }} + > + { + onSearchFlyTo(lat, lng); + setShowSearch(false); + }} + style={{ position: 'relative', top: 0, left: 0, right: 0 }} + /> + + + {/* Cuts drawer */} + setShowCuts(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)' }, + }} + > + + + + {/* Legend drawer */} + setShowLegend(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)' }, + }} + > + + + + ); +} diff --git a/admin/src/components/canvass/CanvassBottomToolbar.tsx b/admin/src/components/canvass/CanvassBottomToolbar.tsx index 7b6125b4..210bb1bc 100644 --- a/admin/src/components/canvass/CanvassBottomToolbar.tsx +++ b/admin/src/components/canvass/CanvassBottomToolbar.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { Button, Badge } from 'antd'; import { AimOutlined, @@ -9,6 +10,51 @@ import { MenuOutlined, } from '@ant-design/icons'; +interface ToolbarButtonProps { + icon: React.ReactNode; + onClick: () => void; + label: string; + disabled?: boolean; + type?: 'default' | 'primary' | 'text' | 'link' | 'dashed'; + ghost?: boolean; + children?: React.ReactNode; +} + +function ToolbarButton({ + icon, + onClick, + label, + disabled, + type = 'default', + ghost, + children, +}: ToolbarButtonProps) { + const [isPressed, setIsPressed] = useState(false); + + return ( + + ); +} + interface CanvassBottomToolbarProps { visitedCount: number; totalCount: number; @@ -47,7 +93,8 @@ export default function CanvassBottomToolbar({
{onMenuOpen && ( - - + ))} + + ) : ( + // Single unit display +
onAddressClick(addresses[0]!.id)}> +
+ {group.baseAddress} +
+ {addresses[0]?.unitNumber && ( +
+ Unit {addresses[0].unitNumber} +
+ )} + {addresses[0]?.firstName && ( +
+ {addresses[0].firstName} {addresses[0].lastName} +
+ )} + {addresses[0]?.lastVisit ? ( + <> +
+ + {VISIT_OUTCOME_LABELS[addresses[0].lastVisit.outcome]} +
+ {addresses[0].lastVisit.visitorName && ( +
+ by {addresses[0].lastVisit.visitorName} +
+ )} + + ) : ( +
Not visited
+ )} + {addresses[0]?.notes && ( +
+ Note: {addresses[0].notes} +
+ )} +
+ Click to record visit +
+
+ )} +
+ + + ); +} + +// Memoize component to prevent re-renders when props haven't changed +export default React.memo(CanvassMarkerGroup, (prevProps, nextProps) => { + // Only re-render if these specific props change + return ( + prevProps.group.locationId === nextProps.group.locationId && + prevProps.group.addresses === nextProps.group.addresses && + prevProps.selectedAddressId === nextProps.selectedAddressId && + prevProps.onAddressClick === nextProps.onAddressClick + ); +}); diff --git a/admin/src/components/canvass/LocationEditDrawer.tsx b/admin/src/components/canvass/LocationEditDrawer.tsx index 1d13ad8d..f9c8f835 100644 --- a/admin/src/components/canvass/LocationEditDrawer.tsx +++ b/admin/src/components/canvass/LocationEditDrawer.tsx @@ -3,12 +3,12 @@ import { Drawer, Form, Input, Select, Switch, Button, message } from 'antd'; import type { CanvassLocation } from '@/types/canvass'; import type { SupportLevel } from '@/types/api'; import { SUPPORT_LEVEL_LABELS } from '@/types/api'; -import { useCanvassStore } from '@/stores/canvass.store'; interface LocationEditDrawerProps { open: boolean; onClose: () => void; location: CanvassLocation | null; + zIndex?: number; } const supportLevelOptions = (['LEVEL_1', 'LEVEL_2', 'LEVEL_3', 'LEVEL_4'] as SupportLevel[]).map( @@ -19,9 +19,10 @@ export default function LocationEditDrawer({ open, onClose, location, + zIndex = 1000, }: LocationEditDrawerProps) { const [form] = Form.useForm(); - const { updateLocationFields } = useCanvassStore(); + // TODO: Update to work with Address model instead of deprecated CanvassLocation useEffect(() => { if (location && open) { @@ -40,20 +41,16 @@ export default function LocationEditDrawer({ const handleSave = async () => { if (!location) return; - try { - const values = await form.validateFields(); - await updateLocationFields(location.id, values); - message.success('Location updated'); - onClose(); - } catch { - message.error('Failed to update location'); - } + message.warning('Location editing temporarily disabled - needs Address model update'); + onClose(); + // TODO: Implement address update API call }; return ( Promise; + onBulkRecord?: (payload: BulkRecordVisitPayload) => Promise; + onNextUnit?: () => void; recording: boolean; userRole?: UserRole; + isMultiUnit?: boolean; + unvisitedCountInBuilding?: number; } const outcomeKeys: VisitOutcome[] = [ @@ -27,18 +33,25 @@ const outcomeKeys: VisitOutcome[] = [ const supportLevelKeys: SupportLevel[] = ['LEVEL_1', 'LEVEL_2', 'LEVEL_3', 'LEVEL_4']; export default function VisitRecordingForm({ - location, + address, sessionId, shiftId, onRecord, + onBulkRecord, + onNextUnit, recording, userRole, + isMultiUnit = false, + unvisitedCountInBuilding = 0, }: VisitRecordingFormProps) { const [outcome, setOutcome] = useState(null); const [supportLevel, setSupportLevel] = useState(undefined); const [signRequested, setSignRequested] = useState(false); const [signSize, setSignSize] = useState(undefined); const [notes, setNotes] = useState(''); + const screens = Grid.useBreakpoint(); + const isMobile = !screens.md; + const isNarrow = !screens.sm; const showDetailFields = userRole !== 'TEMP'; @@ -49,7 +62,7 @@ export default function VisitRecordingForm({ } await onRecord({ - locationId: location.id, + addressId: address.id, // Changed from locationId outcome, supportLevel, signRequested, @@ -59,6 +72,11 @@ export default function VisitRecordingForm({ shiftId, }); + // Auto-advance to next unit if multi-unit + if (isMultiUnit && unvisitedCountInBuilding > 1 && onNextUnit) { + onNextUnit(); + } + // Reset form setOutcome(null); setSupportLevel(undefined); @@ -67,81 +85,166 @@ export default function VisitRecordingForm({ setNotes(''); }; + const handleBulkRecord = (bulkOutcome: 'NOT_HOME' | 'REFUSED' | 'MOVED') => { + if (!onBulkRecord) return; + + Modal.confirm({ + title: ( + + ⚠️ Bulk Record Visit + + ), + icon: , + content: ( +
+

+ This will mark ALL {unvisitedCountInBuilding} unvisited units in this building as: +

+

+ {bulkOutcome.replace(/_/g, ' ')} +

+

+ This action will record {unvisitedCountInBuilding} separate visit entries and cannot be easily undone. +

+
+ ), + okText: `Record ${unvisitedCountInBuilding} Visits`, + okType: 'danger', + okButtonProps: { danger: true }, + cancelText: 'Cancel', + onOk: async () => { + await onBulkRecord({ + locationId: address.location.id, + outcome: bulkOutcome, + notes, + sessionId, + shiftId, + }); + }, + }); + }; + return (
- - {location.address || 'Unknown Address'} - {location.unitNumber && ` #${location.unitNumber}`} - - - {location.firstName && ( - - {location.firstName} {location.lastName} + {address.firstName && ( + + {address.firstName} {address.lastName} )} - - Outcome + {/* Building notes for multi-unit */} + {address.location.buildingNotes && ( + + } + type="info" + showIcon + banner + closable + style={{ marginBottom: 12, fontSize: 11 }} + /> + )} + + + Visit Outcome -
+ {outcomeKeys.map((key) => { const color = VISIT_OUTCOME_COLORS[key]; const selected = outcome === key; return ( - + + ); })} -
+ {showDetailFields && outcome === 'SPOKE_WITH' && ( <> - + Support Level - + {supportLevelKeys.map((key) => ( - + +
+ +
+ {SUPPORT_LEVEL_LABELS[key]} +
+
+ ))} -
-
- {supportLevel && ( - - {SUPPORT_LEVEL_LABELS[supportLevel]} - - )} -
+ - - Sign + + Sign Request + + + + {/* Bulk record and next unit buttons for multi-unit */} + {isMultiUnit && unvisitedCountInBuilding > 1 && ( + + {/* Bulk record dropdown */} + {onBulkRecord && ( + + handleBulkRecord('NOT_HOME'), + }, + { + key: 'REFUSED', + label: `All Refused (${unvisitedCountInBuilding})`, + onClick: () => handleBulkRecord('REFUSED'), + }, + { + key: 'MOVED', + label: `All Moved (${unvisitedCountInBuilding})`, + onClick: () => handleBulkRecord('MOVED'), + }, + ], + }} + > + + + + )} + + {/* Next unit button */} + {onNextUnit && ( + + + + )} + + )} +
); } diff --git a/admin/src/components/canvass/VolunteerMapDrawer.tsx b/admin/src/components/canvass/VolunteerMapDrawer.tsx index 99b88c1e..09ba9e7a 100644 --- a/admin/src/components/canvass/VolunteerMapDrawer.tsx +++ b/admin/src/components/canvass/VolunteerMapDrawer.tsx @@ -1,12 +1,16 @@ import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Drawer, Typography, Button, Select, Statistic, Space, Divider, List } from 'antd'; +import { Drawer, Typography, Button, Select, Statistic, Space, Divider, List, Grid, Alert } from 'antd'; import { HistoryOutlined, LogoutOutlined, PlayCircleOutlined, AimOutlined, + StopOutlined, + ClockCircleOutlined, + CloseOutlined, } from '@ant-design/icons'; +import SessionTimer from './SessionTimer'; import { api } from '@/lib/api'; import { useAuthStore } from '@/stores/auth.store'; import type { MyAssignment, MyCanvassStats } from '@/types/canvass'; @@ -15,21 +19,35 @@ import type { PublicCut } from '@/types/api'; interface VolunteerMapDrawerProps { open: boolean; onClose: () => void; + drawerBodyRef?: React.RefObject; cuts: PublicCut[]; onStartSession: (cutId: string, shiftId?: string) => void; + sessionActive?: boolean; + sessionCutName?: string; + sessionStartedAt?: string; + onEndSession?: () => void; + endingSession?: boolean; } export default function VolunteerMapDrawer({ open, onClose, + drawerBodyRef, cuts, onStartSession, + sessionActive = false, + sessionCutName, + sessionStartedAt, + onEndSession, + endingSession = false, }: VolunteerMapDrawerProps) { const navigate = useNavigate(); const { user, logout } = useAuthStore(); const [stats, setStats] = useState(null); const [assignments, setAssignments] = useState([]); const [freeCutId, setFreeCutId] = useState(null); + const screens = Grid.useBreakpoint(); + const isMobile = !screens.md; useEffect(() => { if (!open) return; @@ -45,15 +63,93 @@ export default function VolunteerMapDrawer({ return ( +
+ {/* Header with drag handle and close button */} +
+ {/* Drag handle at top center */} +
+ + {/* Close button at top right */} +
+ + {/* Active session alert */} + {sessionActive && sessionCutName && ( + <> + + + Active Session: {sessionCutName} + + {sessionStartedAt && ( + + + + + )} + + } + type="info" + showIcon={false} + action={ + onEndSession && ( + + ) + } + style={{ marginBottom: 16 }} + /> + + + )} + {user?.name || user?.email || 'Volunteer'} @@ -71,8 +167,8 @@ export default function VolunteerMapDrawer({ - {/* Assignments */} - {assignments.length > 0 && ( + {/* Assignments (hidden when session active) */} + {!sessionActive && assignments.length > 0 && ( <> My Assignments @@ -111,28 +207,32 @@ export default function VolunteerMapDrawer({ )} - {/* Free session — pick a cut */} - - Start Session (Any Cut) - - - ({ label: c.name, value: c.id }))} + allowClear + /> + + + + )} {/* Navigation links */} +
); } diff --git a/admin/src/components/canvass/VolunteerMapHeader.tsx b/admin/src/components/canvass/VolunteerMapHeader.tsx index 8726018b..0b449706 100644 --- a/admin/src/components/canvass/VolunteerMapHeader.tsx +++ b/admin/src/components/canvass/VolunteerMapHeader.tsx @@ -26,7 +26,8 @@ export default function VolunteerSessionBar({
= { + UNVISITED: 1, // Highest priority - if any address unvisited, show gray + COME_BACK_LATER: 2, + NOT_HOME: 3, + SPOKE_WITH: 4, + LEFT_LITERATURE: 5, + REFUSED: 6, + ALREADY_VOTED: 7, + MOVED: 8, // Lowest priority +}; + +const UNVISITED_COLOR = '#95a5a6'; // Gray + +// Cluster metadata cache to avoid repeated calculations +interface ClusterMetadata { + totalAddresses: number; + hasUnvisited: boolean; + outcomeCounts: Record; + dominantColor: string; +} + +/** + * Creates cluster metadata from marker group with single pass computation. + * Calculates total addresses, visit status, outcome counts, and dominant color. + * + * @param markers - Array of Leaflet markers in the cluster + * @returns Cluster metadata with all computed values + */ +function computeClusterMetadata(markers: L.Marker[]): ClusterMetadata { + let totalAddresses = 0; + let hasUnvisited = false; + const outcomeCounts: Record = { + NOT_HOME: 0, + REFUSED: 0, + MOVED: 0, + ALREADY_VOTED: 0, + SPOKE_WITH: 0, + LEFT_LITERATURE: 0, + COME_BACK_LATER: 0, + }; + + for (const marker of markers) { + const group = (marker.options as any).addressGroup as AddressGroup | undefined; + if (!group) continue; + + totalAddresses += group.addresses.length; + + for (const address of group.addresses) { + if (!address.lastVisit) { + hasUnvisited = true; + } else { + outcomeCounts[address.lastVisit.outcome]++; + } + } + } + + // Calculate dominant color + let dominantColor = UNVISITED_COLOR; + if (hasUnvisited) { + dominantColor = UNVISITED_COLOR; + } else { + let highestPriority = Infinity; + for (const [outcome, count] of Object.entries(outcomeCounts)) { + if (count > 0) { + const priority = OUTCOME_PRIORITY[outcome as VisitOutcome]; + if (priority < highestPriority) { + highestPriority = priority; + dominantColor = VISIT_OUTCOME_COLORS[outcome as VisitOutcome]; + } + } + } + } + + return { totalAddresses, hasUnvisited, outcomeCounts, dominantColor }; +} + +/** + * Determines the dominant outcome color for a cluster of markers. + * Priority: Unvisited > Come Back Later > Not Home > Spoke With > etc. + * + * @param markers - Array of Leaflet markers in the cluster + * @returns Hex color code for the cluster icon + */ +export function getClusterColor(markers: L.Marker[]): string { + // Compute once, return color + const metadata = computeClusterMetadata(markers); + return metadata.dominantColor; +} + +/** + * Creates a custom cluster icon showing total address count and dominant outcome color. + * + * @param cluster - Leaflet marker cluster group + * @returns Custom DivIcon for the cluster + */ +export function createClusterIcon(cluster: any): L.DivIcon { + const markers = cluster.getAllChildMarkers(); + + // Single pass computation - calculates all metadata at once + const metadata = computeClusterMetadata(markers); + + // Generate circular badge HTML + const html = ` +
+ ${metadata.totalAddresses} +
+ `; + + return L.divIcon({ + html, + className: 'marker-cluster-custom', + iconSize: [40, 40], + }); +} + +/** + * Configuration for MarkerClusterGroup component. + * Optimized for performance and UX with address canvassing. + */ +export const CLUSTER_CONFIG = { + maxClusterRadius: 50, // 50px clustering radius + disableClusteringAtZoom: 18, // Disable at street zoom level (buildings clearly visible) + spiderfyOnMaxZoom: false, // Zoom to bounds instead of spider + showCoverageOnHover: false, // No polygon on hover (reduces visual clutter) + zoomToBoundsOnClick: true, // Click cluster → zoom in + animate: true, // Smooth zoom/pan animations + animateAddingMarkers: false, // Skip animation when adding bulk markers (performance) + removeOutsideVisibleBounds: true, // Remove offscreen markers from DOM (memory optimization) + chunkedLoading: true, // Load markers in chunks to prevent UI freeze +}; diff --git a/admin/src/components/email-templates/EmailTemplateEditor.tsx b/admin/src/components/email-templates/EmailTemplateEditor.tsx new file mode 100644 index 00000000..8b23c88a --- /dev/null +++ b/admin/src/components/email-templates/EmailTemplateEditor.tsx @@ -0,0 +1,481 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useOutletContext } from 'react-router-dom'; +import { + Button, + Space, + Typography, + message, + Spin, + Tag, + Grid, + Result, + theme, + Input, + Tabs, + App, +} from 'antd'; +import { + ArrowLeftOutlined, + SaveOutlined, + SendOutlined, + MailOutlined, +} from '@ant-design/icons'; +import Editor from '@monaco-editor/react'; +import { api } from '@/lib/api'; +import type { EmailTemplate, EmailTemplateCategory } from '@/types/api'; +import TestEmailModal from '@/components/email-templates/TestEmailModal'; +import VariablesPanel from '@/components/email-templates/VariablesPanel'; + +const { Text } = Typography; + +interface EmailTemplateEditorProps { + templateId: string; + onClose: () => void; +} + +export default function EmailTemplateEditor({ + templateId, + onClose, +}: EmailTemplateEditorProps) { + const [template, setTemplate] = useState(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [subjectLine, setSubjectLine] = useState(''); + const [htmlContent, setHtmlContent] = useState(''); + const [textContent, setTextContent] = useState(''); + const [activeEditorTab, setActiveEditorTab] = useState<'html' | 'text'>('html'); + const [activeTab, setActiveTab] = useState('variables'); + const [testModalOpen, setTestModalOpen] = useState(false); + const [sampleData, setSampleData] = useState>({}); + const screens = Grid.useBreakpoint(); + const isMobile = !screens.md; + const { token } = theme.useToken(); + const { modal } = App.useApp(); + const { setPageHeader } = useOutletContext<{ setPageHeader: (config: { fullBleed: boolean } | null) => void }>(); + + // Enable fullBleed mode on mount, disable on unmount + useEffect(() => { + setPageHeader({ fullBleed: true }); + return () => setPageHeader(null); + }, [setPageHeader]); + + useEffect(() => { + const fetchTemplate = async () => { + try { + const { data } = await api.get(`/email-templates/${templateId}`); + setTemplate(data); + setSubjectLine(data.subjectLine); + setHtmlContent(data.htmlContent); + setTextContent(data.textContent); + + // Initialize sample data from variables + const initialSampleData: Record = {}; + data.variables.forEach((v) => { + initialSampleData[v.key] = v.sampleValue || ''; + }); + setSampleData(initialSampleData); + } catch { + message.error('Failed to load template'); + onClose(); + } finally { + setLoading(false); + } + }; + fetchTemplate(); + }, [templateId, onClose]); + + const handleSave = useCallback(async () => { + if (!template) return; + setSaving(true); + try { + const { data: updated } = await api.put(`/email-templates/${templateId}`, { + subjectLine, + htmlContent, + textContent, + }); + setTemplate(updated); + message.success('Template saved successfully'); + } catch (err: unknown) { + const msg = + (err as { response?: { data?: { error?: string } } })?.response?.data?.error || + 'Failed to save template'; + message.error(msg); + } finally { + setSaving(false); + } + }, [template, templateId, subjectLine, htmlContent, textContent]); + + // Keyboard shortcut for saving (Ctrl+S) + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key === 's') { + e.preventDefault(); + handleSave(); + } + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [handleSave]); + + const handleClose = () => { + // Check if there are unsaved changes + if (template && ( + subjectLine !== template.subjectLine || + htmlContent !== template.htmlContent || + textContent !== template.textContent + )) { + modal.confirm({ + title: 'Unsaved Changes', + content: 'You have unsaved changes. Are you sure you want to discard them?', + okText: 'Discard', + cancelText: 'Continue Editing', + okType: 'danger', + onOk: () => onClose(), + }); + } else { + onClose(); + } + }; + + const processTemplate = (content: string, data: Record): string => { + let processed = content; + + // Process VIDEO variables first (render as video player in 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 videoHtml = ` +
+ +

+ ⚠️ In actual emails, this will display as a thumbnail with a link +

+
+ `; + processed = processed.replace( + new RegExp(`\\{\\{${variable.key}\\}\\}`, 'g'), + videoHtml + ); + } + }); + } + + // Process TEXT variables + Object.entries(data).forEach(([key, value]) => { + processed = processed.replace(new RegExp(`{{${key}}}`, 'g'), value); + }); + + return processed; + }; + + const getCategoryColor = (category: EmailTemplateCategory): string => { + const colors: Record = { + INFLUENCE: 'blue', + MAP: 'green', + SYSTEM: 'purple', + }; + return colors[category]; + }; + + if (isMobile) { + return ( +
+ + Back to Templates + + } + /> +
+ ); + } + + if (loading || !template) { + return ( +
+ +
+ ); + } + + const processedHtml = processTemplate(htmlContent, sampleData); + const processedText = processTemplate(textContent, sampleData); + + return ( +
+ {/* Top Toolbar with Subject Line */} +
+ + + + +
+ + {/* Main Editor Layout - 60/40 Tabbed */} +
+ {/* Left: Tabbed Editors (60%) */} +
+ setActiveEditorTab(key as 'html' | 'text')} + style={{ flex: 1, display: 'flex', flexDirection: 'column' }} + tabBarStyle={{ margin: 0, paddingLeft: 12, backgroundColor: token.colorBgLayout, minHeight: 38 }} + size="small" + items={[ + { + key: 'html', + label: 'HTML Content', + children: ( + setHtmlContent(value || '')} + options={{ + minimap: { enabled: false }, + fontSize: 14, + wordWrap: 'on', + lineNumbers: 'on', + scrollBeyondLastLine: false, + }} + /> + ), + }, + { + key: 'text', + label: 'Plain Text Content', + children: ( + setTextContent(value || '')} + options={{ + minimap: { enabled: false }, + fontSize: 14, + wordWrap: 'on', + lineNumbers: 'on', + scrollBeyondLastLine: false, + }} + /> + ), + }, + ]} + /> +
+ + {/* Right: Utilities Panel (40%) */} +
+ + ), + }, + { + key: 'htmlPreview', + label: 'HTML Preview', + children: ( + + + + ``` + - Open in browser + - If iframe works here but not in React app, React-specific issue + - If iframe doesn't work here either, service configuration issue + +5. **Verify service URL:** + - Check iframe `src` attribute in code + - Should be `http://qr.cmlite.org` (nginx proxied) + - Try direct URL: `http://localhost:8089` (for testing only) + +--- + +### Problem: Mobile Warning Shows on Desktop + +**Symptoms:** +- Viewing page on desktop computer (large screen) +- Warning "Desktop Recommended" appears instead of iframe +- Screen width clearly > 768px + +**Causes:** +1. Browser zoom level causing incorrect breakpoint detection +2. Browser window width < 768px (narrow window) +3. DevTools open in side-by-side mode reducing width +4. Cached breakpoint state + +**Solutions:** + +1. **Check browser zoom:** + - Press `Ctrl+0` (Windows/Linux) or `Cmd+0` (Mac) to reset zoom to 100% + - Refresh page + +2. **Maximize browser window:** + - Click maximize button or press `F11` for fullscreen + - Ensure window width > 768px + - Refresh page + +3. **Close DevTools or dock to bottom:** + - If DevTools open in side-by-side mode, window width reduced + - Close DevTools (F12) or dock to bottom + - Refresh page + +4. **Check breakpoint detection:** + - Open browser console (F12) + - Type: `window.innerWidth` + - If < 768, window too narrow + - Resize window wider and refresh + +5. **Clear browser cache:** + - Hard refresh: `Ctrl+Shift+R` (Windows/Linux) or `Cmd+Shift+R` (Mac) + - Or clear browser cache entirely + +--- + +### Problem: "Retry" Button Does Nothing + +**Symptoms:** +- Service shows "Offline" +- Click "Retry" button +- Nothing happens, still shows "Offline" + +**Causes:** +1. Service genuinely offline (not a UI bug) +2. Network connectivity issues +3. API endpoint not responding + +**Solutions:** + +1. **Wait before retrying:** + - Service may need time to start + - Wait 30-60 seconds + - Click "Retry" again + +2. **Check Docker containers:** + ```bash + docker compose ps + # Verify mini-qr, nginx, api all show "Up" + + docker compose logs mini-qr + # Check for startup errors + ``` + +3. **Restart services:** + ```bash + docker compose restart mini-qr nginx api + # Wait 30 seconds for services to fully start + # Refresh page and click "Retry" + ``` + +4. **Check network connectivity:** + ```bash + curl http://localhost:8089/health + # Should return 200 OK + + curl http://localhost:4000/api/services/mini-qr/status + # Should return {"online": true} + ``` + +5. **Hard refresh page:** + - `Ctrl+Shift+R` (Windows/Linux) or `Cmd+Shift+R` (Mac) + - Forces fresh status check + +--- + +### Problem: Iframe Content Not Responsive + +**Symptoms:** +- Iframe loads correctly +- Mini QR interface inside iframe is cut off or has horizontal scrollbar +- Cannot see full QR generator form + +**Causes:** +1. Mini QR service not responsive +2. Iframe width constraints +3. Service has minimum width requirement + +**Solutions:** + +1. **Check iframe width:** + - Inspect iframe element in DevTools + - Verify `width: 100%` applied + - Verify parent container has sufficient width + +2. **Remove iframe sandbox (temporarily):** + ```typescript +